1use super::{
34 WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest,
35 WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitDiffRequest,
36 WorkspaceGitRemote, WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest,
37 WorkspaceGitStatus,
38};
39use anyhow::{anyhow, Result};
40use async_trait::async_trait;
41use reqwest::{Client, StatusCode};
42use serde::{Deserialize, Serialize};
43use std::path::PathBuf;
44use std::sync::Arc;
45use std::time::Duration;
46
47pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
49
50pub const DEFAULT_MAX_DIFF_BYTES: u64 = 1024 * 1024;
54
55pub const DEFAULT_MAX_LOG_ENTRIES: usize = 200;
58
59#[derive(Debug, Clone)]
64pub struct RemoteGitBackendConfig {
65 pub base_url: String,
66 pub repo_id: String,
67 pub bearer_token: Option<String>,
68 pub client_cert_pem: Option<PathBuf>,
74 pub client_key_pem: Option<PathBuf>,
77 pub request_timeout: Option<Duration>,
78 pub max_diff_bytes: Option<u64>,
79 pub max_log_entries: Option<usize>,
80}
81
82impl RemoteGitBackendConfig {
83 pub fn new(base_url: impl Into<String>, repo_id: impl Into<String>) -> Self {
84 Self {
85 base_url: base_url.into(),
86 repo_id: repo_id.into(),
87 bearer_token: None,
88 client_cert_pem: None,
89 client_key_pem: None,
90 request_timeout: None,
91 max_diff_bytes: None,
92 max_log_entries: None,
93 }
94 }
95
96 pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
97 self.bearer_token = Some(token.into());
98 self
99 }
100
101 pub fn request_timeout(mut self, timeout: Duration) -> Self {
102 self.request_timeout = Some(timeout);
103 self
104 }
105
106 pub fn max_diff_bytes(mut self, bytes: u64) -> Self {
107 self.max_diff_bytes = Some(bytes);
108 self
109 }
110
111 pub fn max_log_entries(mut self, n: usize) -> Self {
112 self.max_log_entries = Some(n);
113 self
114 }
115
116 pub fn client_cert_pem(mut self, path: impl Into<PathBuf>) -> Self {
117 self.client_cert_pem = Some(path.into());
118 self
119 }
120
121 pub fn client_key_pem(mut self, path: impl Into<PathBuf>) -> Self {
122 self.client_key_pem = Some(path.into());
123 self
124 }
125}
126
127#[derive(Debug, Clone, thiserror::Error)]
131#[error("remote git conflict: {code}: {message}")]
132pub struct RemoteGitConflict {
133 pub code: String,
134 pub message: String,
135}
136
137#[derive(Debug, Clone)]
139pub struct RemoteGitBackend {
140 http: Client,
141 base_url: String,
142 repo_id: String,
143 bearer_token: Option<String>,
144 max_diff_bytes: u64,
145 max_log_entries: usize,
146}
147
148impl RemoteGitBackend {
149 pub fn new(config: RemoteGitBackendConfig) -> Result<Arc<Self>> {
151 if config
152 .bearer_token
153 .as_deref()
154 .map(str::is_empty)
155 .unwrap_or(true)
156 && config.client_cert_pem.is_none()
157 {
158 tracing::warn!(
159 "RemoteGitBackend constructed without bearer token or mTLS; \
160 this is only safe on a trusted localhost gitserver"
161 );
162 }
163
164 let mut builder =
165 Client::builder().timeout(config.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT));
166
167 match (
169 config.client_cert_pem.as_deref(),
170 config.client_key_pem.as_deref(),
171 ) {
172 (Some(cert_path), Some(key_path)) => {
173 let identity = load_mtls_identity(cert_path, key_path)?;
174 builder = builder.identity(identity);
175 }
176 (Some(_), None) => {
177 return Err(anyhow!(
178 "client_cert_pem was set without client_key_pem; both must be provided for mTLS"
179 ));
180 }
181 (None, Some(_)) => {
182 return Err(anyhow!(
183 "client_key_pem was set without client_cert_pem; both must be provided for mTLS"
184 ));
185 }
186 (None, None) => {}
187 }
188
189 let http = builder
190 .build()
191 .map_err(|e| anyhow!("failed to build reqwest client: {}", e))?;
192
193 let base_url = config.base_url.trim_end_matches('/').to_string();
194 Ok(Arc::new(Self {
195 http,
196 base_url,
197 repo_id: config.repo_id,
198 bearer_token: config.bearer_token,
199 max_diff_bytes: config.max_diff_bytes.unwrap_or(DEFAULT_MAX_DIFF_BYTES),
200 max_log_entries: config.max_log_entries.unwrap_or(DEFAULT_MAX_LOG_ENTRIES),
201 }))
202 }
203
204 pub fn base_url(&self) -> &str {
206 &self.base_url
207 }
208
209 pub fn repo_id(&self) -> &str {
211 &self.repo_id
212 }
213
214 pub fn max_diff_bytes(&self) -> u64 {
215 self.max_diff_bytes
216 }
217
218 pub fn max_log_entries(&self) -> usize {
219 self.max_log_entries
220 }
221
222 fn endpoint(&self, op: &str) -> String {
223 format!("{}/v1/repos/{}/git/{}", self.base_url, self.repo_id, op)
224 }
225
226 async fn post_json<Req, Resp>(&self, op: &'static str, body: &Req) -> Result<Resp>
227 where
228 Req: Serialize + ?Sized,
229 Resp: for<'de> Deserialize<'de>,
230 {
231 let url = self.endpoint(op);
232 let mut req = self.http.post(&url).json(body);
233 if let Some(token) = self.bearer_token.as_deref() {
234 if !token.is_empty() {
235 req = req.bearer_auth(token);
236 }
237 }
238
239 let start = std::time::Instant::now();
240 let send_result = req.send().await;
241 let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
242 let ok = matches!(send_result.as_ref(), Ok(r) if r.status().is_success());
243 emit_remote_git_event(op, &self.repo_id, status_code, ok, start.elapsed(), None);
244
245 let resp =
246 send_result.map_err(|e| anyhow!("remote git call '{}' transport error: {}", op, e))?;
247
248 let status = resp.status();
249 if status.is_success() {
250 let parsed = resp
251 .json::<Resp>()
252 .await
253 .map_err(|e| anyhow!("remote git '{}' response body decode error: {}", op, e))?;
254 return Ok(parsed);
255 }
256
257 let body_text = resp.text().await.unwrap_or_default();
258 Err(map_error_response(op, status, &body_text))
259 }
260
261 async fn post_unit<Req>(&self, op: &'static str, body: &Req) -> Result<()>
262 where
263 Req: Serialize + ?Sized,
264 {
265 let url = self.endpoint(op);
266 let mut req = self.http.post(&url).json(body);
267 if let Some(token) = self.bearer_token.as_deref() {
268 if !token.is_empty() {
269 req = req.bearer_auth(token);
270 }
271 }
272
273 let start = std::time::Instant::now();
274 let send_result = req.send().await;
275 let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
276 let ok = matches!(send_result.as_ref(), Ok(r) if r.status().is_success());
277 emit_remote_git_event(op, &self.repo_id, status_code, ok, start.elapsed(), None);
278
279 let resp =
280 send_result.map_err(|e| anyhow!("remote git call '{}' transport error: {}", op, e))?;
281
282 let status = resp.status();
283 if status.is_success() {
284 return Ok(());
285 }
286 let body_text = resp.text().await.unwrap_or_default();
287 Err(map_error_response(op, status, &body_text))
288 }
289
290 async fn post_streamed<Req>(
304 &self,
305 op: &'static str,
306 body: &Req,
307 max_bytes: u64,
308 ) -> Result<Vec<u8>>
309 where
310 Req: Serialize + ?Sized,
311 {
312 use futures::StreamExt;
313
314 let url = self.endpoint(op);
315 let mut req = self.http.post(&url).json(body);
316 if let Some(token) = self.bearer_token.as_deref() {
317 if !token.is_empty() {
318 req = req.bearer_auth(token);
319 }
320 }
321
322 let start = std::time::Instant::now();
323 let send_result = req.send().await;
324 let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
325 let resp = match send_result {
326 Ok(r) => r,
327 Err(e) => {
328 emit_remote_git_event(op, &self.repo_id, status_code, false, start.elapsed(), None);
329 return Err(anyhow!("remote git call '{}' transport error: {}", op, e));
330 }
331 };
332
333 if let Some(len) = resp.content_length() {
335 if len > max_bytes {
336 emit_remote_git_event(
337 op,
338 &self.repo_id,
339 status_code,
340 false,
341 start.elapsed(),
342 Some(len),
343 );
344 return Err(anyhow!(
345 "remote git '{}' Content-Length {} exceeds client cap {} bytes; \
346 refusing to download. Raise max_diff_bytes if the body is legitimate.",
347 op,
348 len,
349 max_bytes
350 ));
351 }
352 }
353
354 let status = resp.status();
356 let mut stream = resp.bytes_stream();
357 let mut buf: Vec<u8> = Vec::new();
358 while let Some(chunk) = stream.next().await {
359 let chunk = chunk.map_err(|e| anyhow!("remote git '{}' stream error: {}", op, e))?;
360 if (buf.len() as u64).saturating_add(chunk.len() as u64) > max_bytes {
361 emit_remote_git_event(
362 op,
363 &self.repo_id,
364 status_code,
365 false,
366 start.elapsed(),
367 Some(buf.len() as u64),
368 );
369 return Err(anyhow!(
370 "remote git '{}' response body exceeded client cap {} bytes mid-stream; \
371 aborting",
372 op,
373 max_bytes
374 ));
375 }
376 buf.extend_from_slice(&chunk);
377 }
378
379 emit_remote_git_event(
380 op,
381 &self.repo_id,
382 status_code,
383 status.is_success(),
384 start.elapsed(),
385 Some(buf.len() as u64),
386 );
387
388 if !status.is_success() {
389 let body_text = String::from_utf8_lossy(&buf).into_owned();
390 return Err(map_error_response(op, status, &body_text));
391 }
392 Ok(buf)
393 }
394}
395
396#[derive(Serialize)]
397struct EmptyReq;
398
399#[derive(Deserialize)]
400struct StatusResp {
401 branch: String,
402 commit: String,
403 #[serde(default)]
404 is_worktree: bool,
405 #[serde(default)]
406 is_dirty: bool,
407 #[serde(default)]
408 dirty_count: usize,
409}
410
411#[derive(Serialize)]
412struct LogReq {
413 max_count: usize,
414}
415
416#[derive(Deserialize)]
417struct LogResp {
418 commits: Vec<CommitDto>,
419}
420
421#[derive(Deserialize)]
422struct CommitDto {
423 id: String,
424 message: String,
425 author: String,
426 date: String,
427}
428
429#[derive(Deserialize)]
430struct BranchesResp {
431 branches: Vec<BranchDto>,
432}
433
434#[derive(Deserialize)]
435struct BranchDto {
436 name: String,
437 #[serde(default)]
438 is_current: bool,
439}
440
441#[derive(Serialize)]
442struct CreateBranchReq<'a> {
443 name: &'a str,
444 base: &'a str,
445}
446
447#[derive(Serialize)]
448struct CheckoutReq<'a> {
449 refspec: &'a str,
450 force: bool,
451}
452
453#[derive(Deserialize)]
454struct CheckoutResp {
455 #[serde(default)]
456 stdout: String,
457}
458
459#[derive(Serialize)]
460struct DiffReq<'a> {
461 target: Option<&'a str>,
462}
463
464#[derive(Deserialize)]
465struct DiffResp {
466 diff: String,
467 #[serde(default)]
468 truncated: bool,
469}
470
471#[derive(Deserialize)]
472struct RemotesResp {
473 remotes: Vec<RemoteDto>,
474}
475
476#[derive(Deserialize)]
477struct RemoteDto {
478 name: String,
479 url: String,
480 #[serde(default = "default_direction")]
481 direction: String,
482}
483
484fn default_direction() -> String {
485 "fetch".to_string()
486}
487
488#[derive(Deserialize)]
489struct ExistsResp {
490 #[serde(default)]
491 is_repository: bool,
492}
493
494#[derive(Deserialize)]
495struct StashesResp {
496 stashes: Vec<StashDto>,
497}
498
499#[derive(Deserialize)]
500struct StashDto {
501 index: usize,
502 #[serde(default)]
503 message: String,
504}
505
506#[derive(Serialize)]
507struct StashCreateReq {
508 #[serde(skip_serializing_if = "Option::is_none")]
509 message: Option<String>,
510 include_untracked: bool,
511}
512
513#[async_trait]
514impl WorkspaceGit for RemoteGitBackend {
515 async fn is_repository(&self) -> Result<bool> {
516 let resp: ExistsResp = self.post_json("exists", &EmptyReq).await?;
517 Ok(resp.is_repository)
518 }
519
520 async fn status(&self) -> Result<WorkspaceGitStatus> {
521 let resp: StatusResp = self.post_json("status", &EmptyReq).await?;
522 Ok(WorkspaceGitStatus {
523 branch: resp.branch,
524 commit: resp.commit,
525 is_worktree: resp.is_worktree,
526 is_dirty: resp.is_dirty,
527 dirty_count: resp.dirty_count,
528 })
529 }
530
531 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
532 let capped = max_count.min(self.max_log_entries);
533 let resp: LogResp = self.post_json("log", &LogReq { max_count: capped }).await?;
534 Ok(resp
535 .commits
536 .into_iter()
537 .map(|c| WorkspaceGitCommit {
538 id: c.id,
539 message: c.message,
540 author: c.author,
541 date: c.date,
542 })
543 .collect())
544 }
545
546 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
547 let resp: BranchesResp = self.post_json("branches", &EmptyReq).await?;
548 Ok(resp
549 .branches
550 .into_iter()
551 .map(|b| WorkspaceGitBranch {
552 name: b.name,
553 is_current: b.is_current,
554 })
555 .collect())
556 }
557
558 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
559 self.post_unit(
560 "branches/create",
561 &CreateBranchReq {
562 name: &request.name,
563 base: &request.base,
564 },
565 )
566 .await
567 }
568
569 async fn checkout(
570 &self,
571 request: WorkspaceGitCheckoutRequest,
572 ) -> Result<WorkspaceGitCheckoutOutput> {
573 let resp: CheckoutResp = self
574 .post_json(
575 "checkout",
576 &CheckoutReq {
577 refspec: &request.refspec,
578 force: request.force,
579 },
580 )
581 .await?;
582 Ok(WorkspaceGitCheckoutOutput {
583 stdout: resp.stdout,
584 })
585 }
586
587 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
588 const DIFF_HARD_CAP_FLOOR: u64 = 64 * 1024;
600 let hard_cap = self
601 .max_diff_bytes
602 .saturating_mul(4)
603 .max(DIFF_HARD_CAP_FLOOR);
604
605 let bytes = self
606 .post_streamed(
607 "diff",
608 &DiffReq {
609 target: request.target.as_deref(),
610 },
611 hard_cap,
612 )
613 .await?;
614 let resp: DiffResp = serde_json::from_slice(&bytes)
615 .map_err(|e| anyhow!("remote git 'diff' response body decode error: {}", e))?;
616
617 if (resp.diff.len() as u64) > self.max_diff_bytes {
618 tracing::debug!(
619 "remote git diff body {} bytes exceeds max_diff_bytes {} — \
620 client-side display truncation",
621 resp.diff.len(),
622 self.max_diff_bytes
623 );
624 let cap = self.max_diff_bytes as usize;
625 let mut trimmed = resp.diff;
626 trimmed.truncate(safe_utf8_truncate(&trimmed, cap));
627 trimmed.push_str("\n... [truncated by client max_diff_bytes]\n");
628 return Ok(trimmed);
629 }
630 if resp.truncated {
631 return Ok(format!("{}\n... [truncated by gitserver]\n", resp.diff));
632 }
633 Ok(resp.diff)
634 }
635
636 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
637 let resp: RemotesResp = self.post_json("remotes", &EmptyReq).await?;
638 Ok(resp
639 .remotes
640 .into_iter()
641 .map(|r| WorkspaceGitRemote {
642 name: r.name,
643 url: r.url,
644 direction: r.direction,
645 })
646 .collect())
647 }
648}
649
650#[async_trait]
651impl WorkspaceGitStashProvider for RemoteGitBackend {
652 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
653 let resp: StashesResp = self.post_json("stashes", &EmptyReq).await?;
654 Ok(resp
655 .stashes
656 .into_iter()
657 .map(|s| WorkspaceGitStash {
658 index: s.index,
659 message: s.message,
660 })
661 .collect())
662 }
663
664 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
665 self.post_unit(
666 "stashes/create",
667 &StashCreateReq {
668 message: request.message,
669 include_untracked: request.include_untracked,
670 },
671 )
672 .await
673 }
674}
675
676fn load_mtls_identity(
686 cert_path: &std::path::Path,
687 key_path: &std::path::Path,
688) -> Result<reqwest::Identity> {
689 let cert = std::fs::read(cert_path).map_err(|e| {
690 anyhow!(
691 "failed to read mTLS client_cert_pem at {}: {}",
692 cert_path.display(),
693 e
694 )
695 })?;
696 let key = std::fs::read(key_path).map_err(|e| {
697 anyhow!(
698 "failed to read mTLS client_key_pem at {}: {}",
699 key_path.display(),
700 e
701 )
702 })?;
703
704 let mut pem = Vec::with_capacity(cert.len() + key.len() + 1);
705 pem.extend_from_slice(&cert);
706 if !cert.ends_with(b"\n") {
707 pem.push(b'\n');
708 }
709 pem.extend_from_slice(&key);
710
711 reqwest::Identity::from_pem(&pem).map_err(|e| {
712 anyhow!(
713 "failed to parse mTLS PEM material (cert={}, key={}): {}",
714 cert_path.display(),
715 key_path.display(),
716 e
717 )
718 })
719}
720
721fn safe_utf8_truncate(s: &str, max_bytes: usize) -> usize {
724 if s.len() <= max_bytes {
725 return s.len();
726 }
727 let mut idx = max_bytes;
728 while idx > 0 && !s.is_char_boundary(idx) {
729 idx -= 1;
730 }
731 idx
732}
733
734fn map_error_response(op: &'static str, status: StatusCode, body: &str) -> anyhow::Error {
742 let parsed: Option<RemoteErrorBody> = serde_json::from_str(body).ok();
743
744 let (code, message) = match parsed {
745 Some(b) => (b.error.code, b.error.message),
746 None => (format!("HTTP_{}", status.as_u16()), body.to_string()),
747 };
748
749 let status_u16 = status.as_u16();
750 if status_u16 == 409 || status_u16 == 422 {
751 return anyhow::Error::new(RemoteGitConflict { code, message });
752 }
753
754 match status_u16 {
755 400 => anyhow!("remote git '{}' bad request: {}: {}", op, code, message),
756 401 | 403 => anyhow!("remote git '{}' auth failed: {}: {}", op, code, message),
757 404 => anyhow!("remote git '{}' not found: {}: {}", op, code, message),
758 500..=599 => anyhow!(
759 "remote git '{}' server error ({}): {}: {}",
760 op,
761 status_u16,
762 code,
763 message
764 ),
765 _ => anyhow!(
766 "remote git '{}' unexpected status {}: {}: {}",
767 op,
768 status_u16,
769 code,
770 message
771 ),
772 }
773}
774
775#[derive(Deserialize)]
776struct RemoteErrorBody {
777 error: RemoteErrorDetail,
778}
779
780#[derive(Deserialize)]
781struct RemoteErrorDetail {
782 code: String,
783 #[serde(default)]
784 message: String,
785}
786
787fn emit_remote_git_event(
801 op: &'static str,
802 repo_id: &str,
803 status: Option<u16>,
804 ok: bool,
805 elapsed: Duration,
806 bytes: Option<u64>,
807) {
808 tracing::debug!(
809 op = format!("git.{}", op),
810 repo_id = %repo_id,
811 status = status.unwrap_or(0),
812 outcome = if ok { "ok" } else { "error" },
813 bytes = bytes.unwrap_or(0),
814 duration_ms = elapsed.as_millis() as u64,
815 );
816}
817
818impl super::WorkspaceServices {
819 pub fn with_remote_git(self: Arc<Self>, config: RemoteGitBackendConfig) -> Result<Arc<Self>> {
831 let backend = RemoteGitBackend::new(config)?;
832 let git: Arc<dyn WorkspaceGit> = backend.clone();
833 let stash: Arc<dyn WorkspaceGitStashProvider> = backend;
834 Ok(self.with_git_provider(git, Some(stash)))
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use serde_json::json;
842 use wiremock::matchers::{header, method, path};
843 use wiremock::{Mock, MockServer, ResponseTemplate};
844
845 async fn server_and_backend() -> (MockServer, Arc<RemoteGitBackend>) {
846 let server = MockServer::start().await;
847 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
848 .bearer_token("test-token")
849 .request_timeout(Duration::from_secs(5));
850 let backend = RemoteGitBackend::new(cfg).unwrap();
851 (server, backend)
852 }
853
854 #[test]
855 fn config_defaults_are_documented() {
856 let cfg = RemoteGitBackendConfig::new("http://localhost", "r");
857 assert!(cfg.bearer_token.is_none());
858 assert!(cfg.client_cert_pem.is_none());
859 assert!(cfg.request_timeout.is_none());
860 assert!(cfg.max_diff_bytes.is_none());
861 assert!(cfg.max_log_entries.is_none());
862 }
863
864 #[test]
865 fn endpoint_url_format_matches_rfc() {
866 let cfg = RemoteGitBackendConfig::new("http://localhost:8080/", "u1/s1");
867 let backend = RemoteGitBackend::new(cfg).unwrap();
868 assert_eq!(backend.base_url(), "http://localhost:8080");
870 assert_eq!(
871 backend.endpoint("status"),
872 "http://localhost:8080/v1/repos/u1/s1/git/status"
873 );
874 assert_eq!(
875 backend.endpoint("branches/create"),
876 "http://localhost:8080/v1/repos/u1/s1/git/branches/create"
877 );
878 }
879
880 #[test]
881 fn mtls_requires_both_cert_and_key() {
882 let cfg = RemoteGitBackendConfig::new("http://localhost", "r").client_cert_pem("/dev/null");
883 let err = RemoteGitBackend::new(cfg).unwrap_err();
884 assert!(
885 err.to_string().contains("client_key_pem"),
886 "missing-key error must name the missing field, got: {}",
887 err
888 );
889
890 let cfg = RemoteGitBackendConfig::new("http://localhost", "r").client_key_pem("/dev/null");
891 let err = RemoteGitBackend::new(cfg).unwrap_err();
892 assert!(
893 err.to_string().contains("client_cert_pem"),
894 "missing-cert error must name the missing field, got: {}",
895 err
896 );
897 }
898
899 #[test]
900 fn mtls_rejects_invalid_pem_blob() {
901 let tmp = tempfile::tempdir().unwrap();
902 let cert = tmp.path().join("cert.pem");
903 let key = tmp.path().join("key.pem");
904 std::fs::write(&cert, b"not a pem").unwrap();
905 std::fs::write(&key, b"also not a pem").unwrap();
906
907 let cfg = RemoteGitBackendConfig::new("http://localhost", "r")
908 .client_cert_pem(&cert)
909 .client_key_pem(&key);
910 let err = RemoteGitBackend::new(cfg).unwrap_err();
911 let msg = err.to_string();
912 assert!(
913 msg.contains("PEM"),
914 "PEM-parse failure must surface clearly, got: {}",
915 msg
916 );
917 assert!(
918 msg.contains(cert.to_str().unwrap()),
919 "error must include the cert path for debugging, got: {}",
920 msg
921 );
922 }
923
924 #[test]
925 fn mtls_accepts_self_signed_pair_from_rcgen() {
926 let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
929 .expect("rcgen self-signed cert");
930 let tmp = tempfile::tempdir().unwrap();
931 let cert_path = tmp.path().join("client.cert.pem");
932 let key_path = tmp.path().join("client.key.pem");
933 std::fs::write(&cert_path, cert.cert.pem()).unwrap();
934 std::fs::write(&key_path, cert.key_pair.serialize_pem()).unwrap();
935
936 let cfg = RemoteGitBackendConfig::new("http://localhost", "r")
937 .bearer_token("t")
938 .client_cert_pem(&cert_path)
939 .client_key_pem(&key_path);
940 let backend = RemoteGitBackend::new(cfg)
941 .expect("valid rcgen-generated PEM pair must produce a backend");
942 assert_eq!(backend.base_url(), "http://localhost");
946 }
947
948 #[test]
949 fn safe_utf8_truncate_respects_boundaries() {
950 assert_eq!(safe_utf8_truncate("hello", 3), 3);
952 assert_eq!(safe_utf8_truncate("hello", 100), 5);
953 let s = "héllo";
955 assert_eq!(safe_utf8_truncate(s, 2), 1);
957 assert_eq!(safe_utf8_truncate(s, 3), 3);
958 }
959
960 #[tokio::test]
961 async fn status_happy_path() {
962 let (server, backend) = server_and_backend().await;
963 Mock::given(method("POST"))
964 .and(path("/v1/repos/test/git/status"))
965 .and(header("authorization", "Bearer test-token"))
966 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
967 "branch": "main",
968 "commit": "abc123",
969 "is_worktree": false,
970 "is_dirty": true,
971 "dirty_count": 3,
972 })))
973 .mount(&server)
974 .await;
975
976 let status = backend.status().await.unwrap();
977 assert_eq!(status.branch, "main");
978 assert_eq!(status.commit, "abc123");
979 assert!(status.is_dirty);
980 assert_eq!(status.dirty_count, 3);
981 }
982
983 #[tokio::test]
984 async fn log_respects_client_max_log_entries() {
985 let server = MockServer::start().await;
986 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
987 .bearer_token("t")
988 .max_log_entries(5);
989 let backend = RemoteGitBackend::new(cfg).unwrap();
990
991 Mock::given(method("POST"))
992 .and(path("/v1/repos/test/git/log"))
993 .and(wiremock::matchers::body_json(json!({"max_count": 5})))
994 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
995 "commits": [
996 {"id":"a","message":"m","author":"x","date":"d"}
997 ]
998 })))
999 .mount(&server)
1000 .await;
1001
1002 let commits = backend.log(100).await.unwrap();
1004 assert_eq!(commits.len(), 1);
1005 assert_eq!(commits[0].id, "a");
1006 }
1007
1008 #[tokio::test]
1009 async fn list_branches_maps_response() {
1010 let (server, backend) = server_and_backend().await;
1011 Mock::given(method("POST"))
1012 .and(path("/v1/repos/test/git/branches"))
1013 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1014 "branches": [
1015 {"name":"main", "is_current":true},
1016 {"name":"feat/x"}
1017 ]
1018 })))
1019 .mount(&server)
1020 .await;
1021
1022 let branches = backend.list_branches().await.unwrap();
1023 assert_eq!(branches.len(), 2);
1024 assert!(branches[0].is_current);
1025 assert!(!branches[1].is_current);
1026 }
1027
1028 #[tokio::test]
1029 async fn create_branch_succeeds_on_201() {
1030 let (server, backend) = server_and_backend().await;
1031 Mock::given(method("POST"))
1032 .and(path("/v1/repos/test/git/branches/create"))
1033 .and(wiremock::matchers::body_json(json!({
1034 "name":"feat/x","base":"main"
1035 })))
1036 .respond_with(ResponseTemplate::new(201).set_body_json(json!({})))
1037 .mount(&server)
1038 .await;
1039
1040 backend
1041 .create_branch(WorkspaceGitCreateBranchRequest {
1042 name: "feat/x".into(),
1043 base: "main".into(),
1044 })
1045 .await
1046 .unwrap();
1047 }
1048
1049 #[tokio::test]
1050 async fn create_branch_409_yields_remote_git_conflict() {
1051 let (server, backend) = server_and_backend().await;
1052 Mock::given(method("POST"))
1053 .and(path("/v1/repos/test/git/branches/create"))
1054 .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1055 "error":{"code":"BRANCH_EXISTS","message":"branch 'feat/x' already exists"}
1056 })))
1057 .mount(&server)
1058 .await;
1059
1060 let err = backend
1061 .create_branch(WorkspaceGitCreateBranchRequest {
1062 name: "feat/x".into(),
1063 base: "main".into(),
1064 })
1065 .await
1066 .unwrap_err();
1067 let conflict = err
1068 .downcast_ref::<RemoteGitConflict>()
1069 .expect("409 must downcast to RemoteGitConflict");
1070 assert_eq!(conflict.code, "BRANCH_EXISTS");
1071 assert!(conflict.message.contains("feat/x"));
1072 }
1073
1074 #[tokio::test]
1075 async fn checkout_returns_stdout() {
1076 let (server, backend) = server_and_backend().await;
1077 Mock::given(method("POST"))
1078 .and(path("/v1/repos/test/git/checkout"))
1079 .and(wiremock::matchers::body_json(json!({
1080 "refspec":"feat/x","force":false
1081 })))
1082 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1083 "stdout":"Switched to branch 'feat/x'"
1084 })))
1085 .mount(&server)
1086 .await;
1087
1088 let out = backend
1089 .checkout(WorkspaceGitCheckoutRequest {
1090 refspec: "feat/x".into(),
1091 force: false,
1092 })
1093 .await
1094 .unwrap();
1095 assert!(out.stdout.contains("feat/x"));
1096 }
1097
1098 #[tokio::test]
1099 async fn checkout_409_dirty_yields_conflict() {
1100 let (server, backend) = server_and_backend().await;
1101 Mock::given(method("POST"))
1102 .and(path("/v1/repos/test/git/checkout"))
1103 .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1104 "error":{"code":"WORKING_TREE_DIRTY","message":"please stash first"}
1105 })))
1106 .mount(&server)
1107 .await;
1108
1109 let err = backend
1110 .checkout(WorkspaceGitCheckoutRequest {
1111 refspec: "main".into(),
1112 force: false,
1113 })
1114 .await
1115 .unwrap_err();
1116 let c = err.downcast_ref::<RemoteGitConflict>().unwrap();
1117 assert_eq!(c.code, "WORKING_TREE_DIRTY");
1118 }
1119
1120 #[tokio::test]
1121 async fn diff_passes_target_through_and_surfaces_server_truncation() {
1122 let (server, backend) = server_and_backend().await;
1123 Mock::given(method("POST"))
1124 .and(path("/v1/repos/test/git/diff"))
1125 .and(wiremock::matchers::body_json(json!({"target":"main"})))
1126 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1127 "diff":"<huge diff>",
1128 "truncated": true
1129 })))
1130 .mount(&server)
1131 .await;
1132
1133 let diff = backend
1134 .diff(WorkspaceGitDiffRequest {
1135 target: Some("main".to_string()),
1136 })
1137 .await
1138 .unwrap();
1139 assert!(diff.contains("truncated by gitserver"));
1140 }
1141
1142 #[tokio::test]
1143 async fn diff_enforces_client_max_diff_bytes() {
1144 let server = MockServer::start().await;
1145 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1146 .bearer_token("t")
1147 .max_diff_bytes(8);
1148 let backend = RemoteGitBackend::new(cfg).unwrap();
1149
1150 Mock::given(method("POST"))
1151 .and(path("/v1/repos/test/git/diff"))
1152 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1153 "diff":"AAAAAAAAAAAAAAAAAAAAAA", "truncated": false
1155 })))
1156 .mount(&server)
1157 .await;
1158
1159 let diff = backend
1160 .diff(WorkspaceGitDiffRequest { target: None })
1161 .await
1162 .unwrap();
1163 assert!(diff.contains("truncated by client max_diff_bytes"));
1164 assert!(diff.starts_with("AAAAAAAA"));
1166 }
1167
1168 #[tokio::test]
1176 async fn diff_rejects_oversized_content_length_upfront() {
1177 let server = MockServer::start().await;
1178 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1179 .bearer_token("t")
1180 .max_diff_bytes(8);
1181 let backend = RemoteGitBackend::new(cfg).unwrap();
1182
1183 let huge_body = vec![b'A'; 1024 * 1024];
1185 Mock::given(method("POST"))
1186 .and(path("/v1/repos/test/git/diff"))
1187 .respond_with(
1188 ResponseTemplate::new(200)
1189 .insert_header("content-type", "application/json")
1190 .set_body_bytes(huge_body),
1191 )
1192 .mount(&server)
1193 .await;
1194
1195 let err = backend
1196 .diff(WorkspaceGitDiffRequest { target: None })
1197 .await
1198 .expect_err("oversized body must be rejected");
1199 let msg = err.to_string();
1200 assert!(
1201 msg.contains("Content-Length") && msg.contains("exceeds client cap"),
1202 "expected eager Content-Length rejection, got: {}",
1203 msg
1204 );
1205 }
1206
1207 #[tokio::test]
1212 async fn diff_aborts_mid_stream_on_cap_exceeded() {
1213 let server = MockServer::start().await;
1214 let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1215 .bearer_token("t")
1216 .max_diff_bytes(8);
1217 let backend = RemoteGitBackend::new(cfg).unwrap();
1218
1219 let big_body = vec![b'A'; 256 * 1024];
1222 Mock::given(method("POST"))
1223 .and(path("/v1/repos/test/git/diff"))
1224 .respond_with(
1225 ResponseTemplate::new(200)
1226 .insert_header("transfer-encoding", "chunked")
1227 .set_body_bytes(big_body),
1228 )
1229 .mount(&server)
1230 .await;
1231
1232 let err = backend
1233 .diff(WorkspaceGitDiffRequest { target: None })
1234 .await
1235 .expect_err("oversized streamed body must be rejected");
1236 let msg = err.to_string();
1237 assert!(
1240 msg.contains("exceeds client cap")
1241 || msg.contains("exceeded client cap")
1242 || msg.contains("Content-Length"),
1243 "expected oversize rejection, got: {}",
1244 msg
1245 );
1246 }
1247
1248 #[tokio::test]
1249 async fn list_remotes_defaults_direction() {
1250 let (server, backend) = server_and_backend().await;
1251 Mock::given(method("POST"))
1252 .and(path("/v1/repos/test/git/remotes"))
1253 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1254 "remotes":[{"name":"origin","url":"git@x:y.git"}]
1255 })))
1256 .mount(&server)
1257 .await;
1258
1259 let rs = backend.list_remotes().await.unwrap();
1260 assert_eq!(rs.len(), 1);
1261 assert_eq!(rs[0].direction, "fetch");
1262 }
1263
1264 #[tokio::test]
1265 async fn is_repository_returns_bool() {
1266 let (server, backend) = server_and_backend().await;
1267 Mock::given(method("POST"))
1268 .and(path("/v1/repos/test/git/exists"))
1269 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1270 "is_repository": true
1271 })))
1272 .mount(&server)
1273 .await;
1274
1275 assert!(backend.is_repository().await.unwrap());
1276 }
1277
1278 #[tokio::test]
1279 async fn list_stashes_maps_response() {
1280 let (server, backend) = server_and_backend().await;
1281 Mock::given(method("POST"))
1282 .and(path("/v1/repos/test/git/stashes"))
1283 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1284 "stashes":[{"index":0,"message":"WIP"}]
1285 })))
1286 .mount(&server)
1287 .await;
1288
1289 let s = backend.list_stashes().await.unwrap();
1290 assert_eq!(s.len(), 1);
1291 assert_eq!(s[0].message, "WIP");
1292 }
1293
1294 #[tokio::test]
1295 async fn stash_create_409_nothing_to_stash() {
1296 let (server, backend) = server_and_backend().await;
1297 Mock::given(method("POST"))
1298 .and(path("/v1/repos/test/git/stashes/create"))
1299 .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1300 "error":{"code":"NOTHING_TO_STASH","message":"clean tree"}
1301 })))
1302 .mount(&server)
1303 .await;
1304
1305 let err = backend
1306 .stash(WorkspaceGitStashRequest {
1307 message: None,
1308 include_untracked: false,
1309 })
1310 .await
1311 .unwrap_err();
1312 let c = err.downcast_ref::<RemoteGitConflict>().unwrap();
1313 assert_eq!(c.code, "NOTHING_TO_STASH");
1314 }
1315
1316 #[tokio::test]
1317 async fn not_found_404_is_generic_anyhow() {
1318 let (server, backend) = server_and_backend().await;
1319 Mock::given(method("POST"))
1320 .and(path("/v1/repos/test/git/status"))
1321 .respond_with(ResponseTemplate::new(404).set_body_json(json!({
1322 "error":{"code":"REPO_NOT_FOUND","message":"unknown repo"}
1323 })))
1324 .mount(&server)
1325 .await;
1326
1327 let err = backend.status().await.unwrap_err();
1328 assert!(err.to_string().contains("not found"), "msg: {}", err);
1329 assert!(err.downcast_ref::<RemoteGitConflict>().is_none());
1330 }
1331
1332 #[tokio::test]
1333 async fn auth_failure_401_is_generic_anyhow() {
1334 let (server, backend) = server_and_backend().await;
1335 Mock::given(method("POST"))
1336 .and(path("/v1/repos/test/git/status"))
1337 .respond_with(ResponseTemplate::new(401).set_body_json(json!({
1338 "error":{"code":"INVALID_TOKEN","message":"bad bearer"}
1339 })))
1340 .mount(&server)
1341 .await;
1342
1343 let err = backend.status().await.unwrap_err();
1344 assert!(err.to_string().contains("auth failed"), "msg: {}", err);
1345 assert!(err.downcast_ref::<RemoteGitConflict>().is_none());
1346 }
1347
1348 #[tokio::test]
1349 async fn server_500_is_generic_anyhow() {
1350 let (server, backend) = server_and_backend().await;
1351 Mock::given(method("POST"))
1352 .and(path("/v1/repos/test/git/status"))
1353 .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
1354 .mount(&server)
1355 .await;
1356
1357 let err = backend.status().await.unwrap_err();
1358 assert!(err.to_string().contains("server error"), "msg: {}", err);
1359 }
1360
1361 #[tokio::test]
1362 async fn non_json_error_body_falls_back_to_http_code() {
1363 let (server, backend) = server_and_backend().await;
1364 Mock::given(method("POST"))
1365 .and(path("/v1/repos/test/git/status"))
1366 .respond_with(ResponseTemplate::new(409).set_body_string("not json"))
1367 .mount(&server)
1368 .await;
1369
1370 let err = backend.status().await.unwrap_err();
1371 let c = err
1375 .downcast_ref::<RemoteGitConflict>()
1376 .expect("409 must yield conflict regardless of body shape");
1377 assert_eq!(c.code, "HTTP_409");
1378 assert_eq!(c.message, "not json");
1379 }
1380
1381 #[tokio::test]
1382 async fn with_remote_git_wires_git_and_stash() {
1383 let services = super::super::WorkspaceServices::local(std::env::temp_dir());
1384 let upgraded = services
1385 .with_remote_git(RemoteGitBackendConfig::new("http://localhost", "r").bearer_token("t"))
1386 .unwrap();
1387 assert!(upgraded.git().is_some());
1388 assert!(upgraded.git_stash().is_some());
1389 assert!(upgraded.git_worktree().is_none());
1392 assert!(upgraded.capabilities().git);
1393 }
1394
1395 #[tokio::test]
1403 async fn with_remote_git_preserves_local_root_and_unrelated_capabilities() {
1404 let temp = tempfile::tempdir().unwrap();
1405 let base = super::super::WorkspaceServices::local(temp.path());
1406 assert!(
1407 base.local_root().is_some(),
1408 "precondition: local() must set local_root"
1409 );
1410 assert!(
1411 base.command_runner().is_some(),
1412 "precondition: local() must wire bash runner"
1413 );
1414 let base_root = base.local_root().map(|p| p.to_path_buf());
1415
1416 let upgraded = base
1417 .with_remote_git(RemoteGitBackendConfig::new("http://localhost", "r").bearer_token("t"))
1418 .unwrap();
1419
1420 assert!(upgraded.git().is_some());
1422 assert!(upgraded.capabilities().git);
1423 assert_eq!(
1425 upgraded.local_root().map(|p| p.to_path_buf()),
1426 base_root,
1427 "local_root must survive with_remote_git"
1428 );
1429 assert!(
1430 upgraded.command_runner().is_some(),
1431 "command_runner must survive with_remote_git"
1432 );
1433 assert!(
1434 upgraded.search().is_some(),
1435 "search provider must survive with_remote_git"
1436 );
1437 assert!(upgraded.git_worktree().is_none());
1439 }
1440
1441 #[tokio::test]
1449 async fn git_tool_status_works_through_remote_backend() {
1450 use crate::tools::{Tool, ToolContext};
1451
1452 let server = MockServer::start().await;
1453 Mock::given(method("POST"))
1455 .and(path("/v1/repos/u1/s1/git/exists"))
1456 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"is_repository": true})))
1457 .mount(&server)
1458 .await;
1459 Mock::given(method("POST"))
1460 .and(path("/v1/repos/u1/s1/git/status"))
1461 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1462 "branch":"main",
1463 "commit":"deadbeef",
1464 "is_worktree": false,
1465 "is_dirty": false,
1466 "dirty_count": 0,
1467 })))
1468 .mount(&server)
1469 .await;
1470
1471 let base = super::super::WorkspaceServices::local(std::env::temp_dir());
1472 let services = base
1473 .with_remote_git(RemoteGitBackendConfig::new(server.uri(), "u1/s1").bearer_token("tok"))
1474 .unwrap();
1475
1476 let tool = crate::tools::builtin::git::GitTool;
1477 let ctx = ToolContext::new(std::env::temp_dir()).with_workspace_services(services);
1478
1479 let result = tool
1480 .execute(&json!({"command": "status"}), &ctx)
1481 .await
1482 .unwrap();
1483 assert!(result.success, "tool output: {}", result.content);
1484 assert!(
1485 result.content.contains("main"),
1486 "expected branch name in output: {}",
1487 result.content
1488 );
1489 assert!(
1490 result.content.contains("deadbeef"),
1491 "expected commit hash in output: {}",
1492 result.content
1493 );
1494 assert!(
1495 result.content.contains("clean"),
1496 "expected clean status in output: {}",
1497 result.content
1498 );
1499 }
1500}