Skip to main content

a3s_code_core/workspace/
remote_git.rs

1//! Remote `WorkspaceGit` backend.
2//!
3//! Talks an HTTP/JSON protocol to a host-operated `gitserver` so non-local
4//! workspaces (S3, future container / DFS) can offer the `git` tool to
5//! the model. The full protocol specification lives in the RFC at
6//! `apps/docs/content/docs/en/code/rfcs/workspace-remote-git.mdx`. This
7//! module is the Rust client side of that protocol.
8//!
9//! # Capabilities
10//!
11//! Implements [`WorkspaceGit`] in full and [`WorkspaceGitStashProvider`].
12//! Deliberately does **not** implement [`WorkspaceGitWorktreeProvider`]:
13//! worktrees are a local-filesystem concept that does not map cleanly onto
14//! a remote service. Tools that need per-branch isolation on remote
15//! workspaces should use separate sessions with separate `repo_id`s.
16//!
17//! # Observability
18//!
19//! Every HTTP call emits a `tracing::debug!` event with the same field
20//! shape used by `S3WorkspaceBackend` (op / target / outcome / bytes /
21//! duration_ms / status). Hosts that already meter S3 cost via that
22//! channel pick up gitserver cost for free.
23//!
24//! # Authentication
25//!
26//! Bearer token (default). Empty token mode is permitted for localhost
27//! development and emits a warn on construction. mTLS is supported by
28//! setting both `client_cert_pem` and `client_key_pem` on the config —
29//! the files are read at backend construction, concatenated (cert + key)
30//! and handed to `reqwest::Identity::from_pem`. Setting only one of the
31//! pair fails at construction with a clear error.
32
33use 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
47/// Default per-call HTTP timeout, applied to every request the client makes.
48pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
49
50/// Default body-size cap for `diff` responses. The server should honour the
51/// same ceiling and set `truncated: true` if it had to clip; this is the
52/// client-side defence.
53pub const DEFAULT_MAX_DIFF_BYTES: u64 = 1024 * 1024;
54
55/// Default ceiling on `log` `max_count` — caps the per-call response size
56/// even when the model requests more.
57pub const DEFAULT_MAX_LOG_ENTRIES: usize = 200;
58
59/// Configuration for a [`RemoteGitBackend`].
60///
61/// `base_url` should not have a trailing slash; the client constructs
62/// `{base_url}/v1/repos/{repo_id}/git/{op}` per the RFC.
63#[derive(Debug, Clone)]
64pub struct RemoteGitBackendConfig {
65    pub base_url: String,
66    pub repo_id: String,
67    pub bearer_token: Option<String>,
68    /// mTLS client certificate path (PEM). When set together with
69    /// `client_key_pem`, the backend reads both files at construction,
70    /// concatenates them, and configures `reqwest::Identity::from_pem`
71    /// on the HTTP client. Setting only one of the pair errors at
72    /// construction.
73    pub client_cert_pem: Option<PathBuf>,
74    /// mTLS client private key path (PEM). See `client_cert_pem`. The key
75    /// must be in PKCS#8 PEM format for the `rustls-tls` backend.
76    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/// Error returned for HTTP 409 / 422 responses that carry a recoverable
128/// failure code. Tools downcast with `anyhow::Error::downcast_ref` to react
129/// — for example, retrying after a `WORKING_TREE_DIRTY` by stashing first.
130#[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/// Client for a remote `gitserver`. See module docs / RFC for the protocol.
138#[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    /// Build a backend from declarative configuration.
150    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 = Client::builder()
165            .no_proxy()
166            .timeout(config.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT));
167
168        // mTLS: both files must be present, otherwise fail closed.
169        match (
170            config.client_cert_pem.as_deref(),
171            config.client_key_pem.as_deref(),
172        ) {
173            (Some(cert_path), Some(key_path)) => {
174                let identity = load_mtls_identity(cert_path, key_path)?;
175                builder = builder.identity(identity);
176            }
177            (Some(_), None) => {
178                return Err(anyhow!(
179                    "client_cert_pem was set without client_key_pem; both must be provided for mTLS"
180                ));
181            }
182            (None, Some(_)) => {
183                return Err(anyhow!(
184                    "client_key_pem was set without client_cert_pem; both must be provided for mTLS"
185                ));
186            }
187            (None, None) => {}
188        }
189
190        let http = builder
191            .build()
192            .map_err(|e| anyhow!("failed to build reqwest client: {}", e))?;
193
194        let base_url = config.base_url.trim_end_matches('/').to_string();
195        Ok(Arc::new(Self {
196            http,
197            base_url,
198            repo_id: config.repo_id,
199            bearer_token: config.bearer_token,
200            max_diff_bytes: config.max_diff_bytes.unwrap_or(DEFAULT_MAX_DIFF_BYTES),
201            max_log_entries: config.max_log_entries.unwrap_or(DEFAULT_MAX_LOG_ENTRIES),
202        }))
203    }
204
205    /// Base URL the client is configured to use (no trailing slash).
206    pub fn base_url(&self) -> &str {
207        &self.base_url
208    }
209
210    /// Opaque repository identifier passed in every request URL.
211    pub fn repo_id(&self) -> &str {
212        &self.repo_id
213    }
214
215    pub fn max_diff_bytes(&self) -> u64 {
216        self.max_diff_bytes
217    }
218
219    pub fn max_log_entries(&self) -> usize {
220        self.max_log_entries
221    }
222
223    fn endpoint(&self, op: &str) -> String {
224        format!("{}/v1/repos/{}/git/{}", self.base_url, self.repo_id, op)
225    }
226
227    async fn post_json<Req, Resp>(&self, op: &'static str, body: &Req) -> Result<Resp>
228    where
229        Req: Serialize + ?Sized,
230        Resp: for<'de> Deserialize<'de>,
231    {
232        let url = self.endpoint(op);
233        let mut req = self.http.post(&url).json(body);
234        if let Some(token) = self.bearer_token.as_deref() {
235            if !token.is_empty() {
236                req = req.bearer_auth(token);
237            }
238        }
239
240        let start = std::time::Instant::now();
241        let send_result = req.send().await;
242        let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
243        let ok = matches!(send_result.as_ref(), Ok(r) if r.status().is_success());
244        emit_remote_git_event(op, &self.repo_id, status_code, ok, start.elapsed(), None);
245
246        let resp =
247            send_result.map_err(|e| anyhow!("remote git call '{}' transport error: {}", op, e))?;
248
249        let status = resp.status();
250        if status.is_success() {
251            let parsed = resp
252                .json::<Resp>()
253                .await
254                .map_err(|e| anyhow!("remote git '{}' response body decode error: {}", op, e))?;
255            return Ok(parsed);
256        }
257
258        let body_text = resp.text().await.unwrap_or_default();
259        Err(map_error_response(op, status, &body_text))
260    }
261
262    async fn post_unit<Req>(&self, op: &'static str, body: &Req) -> Result<()>
263    where
264        Req: Serialize + ?Sized,
265    {
266        let url = self.endpoint(op);
267        let mut req = self.http.post(&url).json(body);
268        if let Some(token) = self.bearer_token.as_deref() {
269            if !token.is_empty() {
270                req = req.bearer_auth(token);
271            }
272        }
273
274        let start = std::time::Instant::now();
275        let send_result = req.send().await;
276        let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
277        let ok = matches!(send_result.as_ref(), Ok(r) if r.status().is_success());
278        emit_remote_git_event(op, &self.repo_id, status_code, ok, start.elapsed(), None);
279
280        let resp =
281            send_result.map_err(|e| anyhow!("remote git call '{}' transport error: {}", op, e))?;
282
283        let status = resp.status();
284        if status.is_success() {
285            return Ok(());
286        }
287        let body_text = resp.text().await.unwrap_or_default();
288        Err(map_error_response(op, status, &body_text))
289    }
290
291    /// Like [`Self::post_json`] but with a hard cap on the streamed response
292    /// body in bytes, intended for endpoints that can legitimately return
293    /// large payloads (`diff`).
294    ///
295    /// Two layers of defence:
296    /// 1. If the server sends a `Content-Length` greater than `max_bytes`,
297    ///    the request is rejected before any body is consumed.
298    /// 2. Otherwise the body is streamed; once the accumulated buffer
299    ///    exceeds `max_bytes`, the stream is dropped and the call returns
300    ///    an error. Memory is bounded at `max_bytes + one chunk`.
301    ///
302    /// Used by [`WorkspaceGit::diff`]; protects against a misbehaving
303    /// gitserver that ignores the client's soft `max_diff_bytes`.
304    async fn post_streamed<Req>(
305        &self,
306        op: &'static str,
307        body: &Req,
308        max_bytes: u64,
309    ) -> Result<Vec<u8>>
310    where
311        Req: Serialize + ?Sized,
312    {
313        use futures::StreamExt;
314
315        let url = self.endpoint(op);
316        let mut req = self.http.post(&url).json(body);
317        if let Some(token) = self.bearer_token.as_deref() {
318            if !token.is_empty() {
319                req = req.bearer_auth(token);
320            }
321        }
322
323        let start = std::time::Instant::now();
324        let send_result = req.send().await;
325        let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
326        let resp = match send_result {
327            Ok(r) => r,
328            Err(e) => {
329                emit_remote_git_event(op, &self.repo_id, status_code, false, start.elapsed(), None);
330                return Err(anyhow!("remote git call '{}' transport error: {}", op, e));
331            }
332        };
333
334        // Layer 1: eager rejection on advertised oversized body.
335        if let Some(len) = resp.content_length() {
336            if len > max_bytes {
337                emit_remote_git_event(
338                    op,
339                    &self.repo_id,
340                    status_code,
341                    false,
342                    start.elapsed(),
343                    Some(len),
344                );
345                return Err(anyhow!(
346                    "remote git '{}' Content-Length {} exceeds client cap {} bytes; \
347                     refusing to download. Raise max_diff_bytes if the body is legitimate.",
348                    op,
349                    len,
350                    max_bytes
351                ));
352            }
353        }
354
355        // Layer 2: stream-bound accumulation.
356        let status = resp.status();
357        let mut stream = resp.bytes_stream();
358        let mut buf: Vec<u8> = Vec::new();
359        while let Some(chunk) = stream.next().await {
360            let chunk = chunk.map_err(|e| anyhow!("remote git '{}' stream error: {}", op, e))?;
361            if (buf.len() as u64).saturating_add(chunk.len() as u64) > max_bytes {
362                emit_remote_git_event(
363                    op,
364                    &self.repo_id,
365                    status_code,
366                    false,
367                    start.elapsed(),
368                    Some(buf.len() as u64),
369                );
370                return Err(anyhow!(
371                    "remote git '{}' response body exceeded client cap {} bytes mid-stream; \
372                     aborting",
373                    op,
374                    max_bytes
375                ));
376            }
377            buf.extend_from_slice(&chunk);
378        }
379
380        emit_remote_git_event(
381            op,
382            &self.repo_id,
383            status_code,
384            status.is_success(),
385            start.elapsed(),
386            Some(buf.len() as u64),
387        );
388
389        if !status.is_success() {
390            let body_text = String::from_utf8_lossy(&buf).into_owned();
391            return Err(map_error_response(op, status, &body_text));
392        }
393        Ok(buf)
394    }
395}
396
397#[derive(Serialize)]
398struct EmptyReq;
399
400#[derive(Deserialize)]
401struct StatusResp {
402    branch: String,
403    commit: String,
404    #[serde(default)]
405    is_worktree: bool,
406    #[serde(default)]
407    is_dirty: bool,
408    #[serde(default)]
409    dirty_count: usize,
410}
411
412#[derive(Serialize)]
413struct LogReq {
414    max_count: usize,
415}
416
417#[derive(Deserialize)]
418struct LogResp {
419    commits: Vec<CommitDto>,
420}
421
422#[derive(Deserialize)]
423struct CommitDto {
424    id: String,
425    message: String,
426    author: String,
427    date: String,
428}
429
430#[derive(Deserialize)]
431struct BranchesResp {
432    branches: Vec<BranchDto>,
433}
434
435#[derive(Deserialize)]
436struct BranchDto {
437    name: String,
438    #[serde(default)]
439    is_current: bool,
440}
441
442#[derive(Serialize)]
443struct CreateBranchReq<'a> {
444    name: &'a str,
445    base: &'a str,
446}
447
448#[derive(Serialize)]
449struct CheckoutReq<'a> {
450    refspec: &'a str,
451    force: bool,
452}
453
454#[derive(Deserialize)]
455struct CheckoutResp {
456    #[serde(default)]
457    stdout: String,
458}
459
460#[derive(Serialize)]
461struct DiffReq<'a> {
462    target: Option<&'a str>,
463}
464
465#[derive(Deserialize)]
466struct DiffResp {
467    diff: String,
468    #[serde(default)]
469    truncated: bool,
470}
471
472#[derive(Deserialize)]
473struct RemotesResp {
474    remotes: Vec<RemoteDto>,
475}
476
477#[derive(Deserialize)]
478struct RemoteDto {
479    name: String,
480    url: String,
481    #[serde(default = "default_direction")]
482    direction: String,
483}
484
485fn default_direction() -> String {
486    "fetch".to_string()
487}
488
489#[derive(Deserialize)]
490struct ExistsResp {
491    #[serde(default)]
492    is_repository: bool,
493}
494
495#[derive(Deserialize)]
496struct StashesResp {
497    stashes: Vec<StashDto>,
498}
499
500#[derive(Deserialize)]
501struct StashDto {
502    index: usize,
503    #[serde(default)]
504    message: String,
505}
506
507#[derive(Serialize)]
508struct StashCreateReq {
509    #[serde(skip_serializing_if = "Option::is_none")]
510    message: Option<String>,
511    include_untracked: bool,
512}
513
514#[async_trait]
515impl WorkspaceGit for RemoteGitBackend {
516    async fn is_repository(&self) -> Result<bool> {
517        let resp: ExistsResp = self.post_json("exists", &EmptyReq).await?;
518        Ok(resp.is_repository)
519    }
520
521    async fn status(&self) -> Result<WorkspaceGitStatus> {
522        let resp: StatusResp = self.post_json("status", &EmptyReq).await?;
523        Ok(WorkspaceGitStatus {
524            branch: resp.branch,
525            commit: resp.commit,
526            is_worktree: resp.is_worktree,
527            is_dirty: resp.is_dirty,
528            dirty_count: resp.dirty_count,
529        })
530    }
531
532    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
533        let capped = max_count.min(self.max_log_entries);
534        let resp: LogResp = self.post_json("log", &LogReq { max_count: capped }).await?;
535        Ok(resp
536            .commits
537            .into_iter()
538            .map(|c| WorkspaceGitCommit {
539                id: c.id,
540                message: c.message,
541                author: c.author,
542                date: c.date,
543            })
544            .collect())
545    }
546
547    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
548        let resp: BranchesResp = self.post_json("branches", &EmptyReq).await?;
549        Ok(resp
550            .branches
551            .into_iter()
552            .map(|b| WorkspaceGitBranch {
553                name: b.name,
554                is_current: b.is_current,
555            })
556            .collect())
557    }
558
559    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
560        self.post_unit(
561            "branches/create",
562            &CreateBranchReq {
563                name: &request.name,
564                base: &request.base,
565            },
566        )
567        .await
568    }
569
570    async fn checkout(
571        &self,
572        request: WorkspaceGitCheckoutRequest,
573    ) -> Result<WorkspaceGitCheckoutOutput> {
574        let resp: CheckoutResp = self
575            .post_json(
576                "checkout",
577                &CheckoutReq {
578                    refspec: &request.refspec,
579                    force: request.force,
580                },
581            )
582            .await?;
583        Ok(WorkspaceGitCheckoutOutput {
584            stdout: resp.stdout,
585        })
586    }
587
588    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
589        // Two-layered defence against a misbehaving gitserver:
590        //
591        // * **Hard memory cap** = `max_diff_bytes * 4` (floor 64 KiB). The
592        //   request streams the body and aborts once this is exceeded, so a
593        //   server returning a 1 GiB diff never gets fully buffered. We
594        //   allow 4× slack over the soft cap so legitimate-but-large diffs
595        //   reach the parser and can be display-truncated below.
596        // * **Soft display cap** = `max_diff_bytes`. Applied after JSON
597        //   decode: the diff text we hand back to the tool is shortened to
598        //   this many bytes (UTF-8-safe) so callers see a useful preview
599        //   without the model context bloating.
600        const DIFF_HARD_CAP_FLOOR: u64 = 64 * 1024;
601        let hard_cap = self
602            .max_diff_bytes
603            .saturating_mul(4)
604            .max(DIFF_HARD_CAP_FLOOR);
605
606        let bytes = self
607            .post_streamed(
608                "diff",
609                &DiffReq {
610                    target: request.target.as_deref(),
611                },
612                hard_cap,
613            )
614            .await?;
615        let resp: DiffResp = serde_json::from_slice(&bytes)
616            .map_err(|e| anyhow!("remote git 'diff' response body decode error: {}", e))?;
617
618        if (resp.diff.len() as u64) > self.max_diff_bytes {
619            tracing::debug!(
620                "remote git diff body {} bytes exceeds max_diff_bytes {} — \
621                 client-side display truncation",
622                resp.diff.len(),
623                self.max_diff_bytes
624            );
625            let cap = self.max_diff_bytes as usize;
626            let mut trimmed = resp.diff;
627            trimmed.truncate(safe_utf8_truncate(&trimmed, cap));
628            trimmed.push_str("\n... [truncated by client max_diff_bytes]\n");
629            return Ok(trimmed);
630        }
631        if resp.truncated {
632            return Ok(format!("{}\n... [truncated by gitserver]\n", resp.diff));
633        }
634        Ok(resp.diff)
635    }
636
637    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
638        let resp: RemotesResp = self.post_json("remotes", &EmptyReq).await?;
639        Ok(resp
640            .remotes
641            .into_iter()
642            .map(|r| WorkspaceGitRemote {
643                name: r.name,
644                url: r.url,
645                direction: r.direction,
646            })
647            .collect())
648    }
649}
650
651#[async_trait]
652impl WorkspaceGitStashProvider for RemoteGitBackend {
653    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
654        let resp: StashesResp = self.post_json("stashes", &EmptyReq).await?;
655        Ok(resp
656            .stashes
657            .into_iter()
658            .map(|s| WorkspaceGitStash {
659                index: s.index,
660                message: s.message,
661            })
662            .collect())
663    }
664
665    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
666        self.post_unit(
667            "stashes/create",
668            &StashCreateReq {
669                message: request.message,
670                include_untracked: request.include_untracked,
671            },
672        )
673        .await
674    }
675}
676
677/// Read the mTLS cert + key PEM files and assemble a `reqwest::Identity`.
678///
679/// `reqwest::Identity::from_pem` (with the `rustls-tls` backend) wants a
680/// single PEM blob containing the certificate chain followed by the
681/// private key. We concatenate the two files with a newline separator —
682/// stray trailing newlines in either file are tolerated by the PEM
683/// parser. Errors at every step (file I/O, PEM parsing) are mapped to
684/// `anyhow` with the source path included so misconfigurations surface
685/// clearly.
686fn load_mtls_identity(
687    cert_path: &std::path::Path,
688    key_path: &std::path::Path,
689) -> Result<reqwest::Identity> {
690    let cert = std::fs::read(cert_path).map_err(|e| {
691        anyhow!(
692            "failed to read mTLS client_cert_pem at {}: {}",
693            cert_path.display(),
694            e
695        )
696    })?;
697    let key = std::fs::read(key_path).map_err(|e| {
698        anyhow!(
699            "failed to read mTLS client_key_pem at {}: {}",
700            key_path.display(),
701            e
702        )
703    })?;
704
705    let mut pem = Vec::with_capacity(cert.len() + key.len() + 1);
706    pem.extend_from_slice(&cert);
707    if !cert.ends_with(b"\n") {
708        pem.push(b'\n');
709    }
710    pem.extend_from_slice(&key);
711
712    reqwest::Identity::from_pem(&pem).map_err(|e| {
713        anyhow!(
714            "failed to parse mTLS PEM material (cert={}, key={}): {}",
715            cert_path.display(),
716            key_path.display(),
717            e
718        )
719    })
720}
721
722/// Truncate `s` to at most `max_bytes`, rounding down to the nearest UTF-8
723/// character boundary to keep the result a valid `&str`.
724fn safe_utf8_truncate(s: &str, max_bytes: usize) -> usize {
725    if s.len() <= max_bytes {
726        return s.len();
727    }
728    let mut idx = max_bytes;
729    while idx > 0 && !s.is_char_boundary(idx) {
730        idx -= 1;
731    }
732    idx
733}
734
735/// Map a non-2xx response to an `anyhow::Error`, attaching a typed
736/// [`RemoteGitConflict`] when the server returned a recoverable code under
737/// 409 or 422.
738///
739/// Synchronous and takes the pre-fetched response body so it can be shared
740/// between callers that hold a `reqwest::Response` and callers that have
741/// already streamed the body into a `Vec<u8>` (for size-capped paths).
742fn map_error_response(op: &'static str, status: StatusCode, body: &str) -> anyhow::Error {
743    let parsed: Option<RemoteErrorBody> = serde_json::from_str(body).ok();
744
745    let (code, message) = match parsed {
746        Some(b) => (b.error.code, b.error.message),
747        None => (format!("HTTP_{}", status.as_u16()), body.to_string()),
748    };
749
750    let status_u16 = status.as_u16();
751    if status_u16 == 409 || status_u16 == 422 {
752        return anyhow::Error::new(RemoteGitConflict { code, message });
753    }
754
755    match status_u16 {
756        400 => anyhow!("remote git '{}' bad request: {}: {}", op, code, message),
757        401 | 403 => anyhow!("remote git '{}' auth failed: {}: {}", op, code, message),
758        404 => anyhow!("remote git '{}' not found: {}: {}", op, code, message),
759        500..=599 => anyhow!(
760            "remote git '{}' server error ({}): {}: {}",
761            op,
762            status_u16,
763            code,
764            message
765        ),
766        _ => anyhow!(
767            "remote git '{}' unexpected status {}: {}: {}",
768            op,
769            status_u16,
770            code,
771            message
772        ),
773    }
774}
775
776#[derive(Deserialize)]
777struct RemoteErrorBody {
778    error: RemoteErrorDetail,
779}
780
781#[derive(Deserialize)]
782struct RemoteErrorDetail {
783    code: String,
784    #[serde(default)]
785    message: String,
786}
787
788/// Emit a structured `tracing::debug!` event for a single gitserver call.
789///
790/// Mirrors the metering shape used by `S3WorkspaceBackend::emit_s3_call_event`
791/// so a single subscriber can meter both backends. Fields:
792///
793/// | Field         | Meaning                                          |
794/// |---------------|--------------------------------------------------|
795/// | `op`          | gitserver op (`status`, `log`, `diff`, ...)      |
796/// | `repo_id`     | opaque repo identifier                           |
797/// | `status`      | HTTP status code (when the request reached server) |
798/// | `outcome`     | `ok` \| `error`                                   |
799/// | `bytes`       | response body length, when known                  |
800/// | `duration_ms` | wall-clock                                       |
801fn emit_remote_git_event(
802    op: &'static str,
803    repo_id: &str,
804    status: Option<u16>,
805    ok: bool,
806    elapsed: Duration,
807    bytes: Option<u64>,
808) {
809    tracing::debug!(
810        op = format!("git.{}", op),
811        repo_id = %repo_id,
812        status = status.unwrap_or(0),
813        outcome = if ok { "ok" } else { "error" },
814        bytes = bytes.unwrap_or(0),
815        duration_ms = elapsed.as_millis() as u64,
816    );
817}
818
819impl super::WorkspaceServices {
820    /// Attach a remote git provider to an existing [`WorkspaceServices`].
821    ///
822    /// Returns a new `Arc<WorkspaceServices>` with `git` and `git_stash`
823    /// wired to the remote backend. The original `WorkspaceServices` is
824    /// not mutated. `git_worktree` is intentionally reset to `None` —
825    /// worktrees are a local-filesystem concept that does not map cleanly
826    /// onto a remote service (see RFC §8). All other fields — including
827    /// `local_root`, the command runner, the search provider, the
828    /// optional `file_system_ext` (S3 CAS), and `operation_timeout` — are
829    /// preserved verbatim via
830    /// [`super::WorkspaceServices::with_git_provider`].
831    pub fn with_remote_git(self: Arc<Self>, config: RemoteGitBackendConfig) -> Result<Arc<Self>> {
832        let backend = RemoteGitBackend::new(config)?;
833        let git: Arc<dyn WorkspaceGit> = backend.clone();
834        let stash: Arc<dyn WorkspaceGitStashProvider> = backend;
835        Ok(self.with_git_provider(git, Some(stash)))
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use serde_json::json;
843    use wiremock::matchers::{header, method, path};
844    use wiremock::{Mock, MockServer, ResponseTemplate};
845
846    async fn server_and_backend() -> (MockServer, Arc<RemoteGitBackend>) {
847        let server = MockServer::start().await;
848        let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
849            .bearer_token("test-token")
850            .request_timeout(Duration::from_secs(5));
851        let backend = RemoteGitBackend::new(cfg).unwrap();
852        (server, backend)
853    }
854
855    #[test]
856    fn config_defaults_are_documented() {
857        let cfg = RemoteGitBackendConfig::new("http://localhost", "r");
858        assert!(cfg.bearer_token.is_none());
859        assert!(cfg.client_cert_pem.is_none());
860        assert!(cfg.request_timeout.is_none());
861        assert!(cfg.max_diff_bytes.is_none());
862        assert!(cfg.max_log_entries.is_none());
863    }
864
865    #[test]
866    fn endpoint_url_format_matches_rfc() {
867        let cfg = RemoteGitBackendConfig::new("http://localhost:8080/", "u1/s1");
868        let backend = RemoteGitBackend::new(cfg).unwrap();
869        // Trailing slash on base_url is stripped.
870        assert_eq!(backend.base_url(), "http://localhost:8080");
871        assert_eq!(
872            backend.endpoint("status"),
873            "http://localhost:8080/v1/repos/u1/s1/git/status"
874        );
875        assert_eq!(
876            backend.endpoint("branches/create"),
877            "http://localhost:8080/v1/repos/u1/s1/git/branches/create"
878        );
879    }
880
881    #[test]
882    fn mtls_requires_both_cert_and_key() {
883        let cfg = RemoteGitBackendConfig::new("http://localhost", "r").client_cert_pem("/dev/null");
884        let err = RemoteGitBackend::new(cfg).unwrap_err();
885        assert!(
886            err.to_string().contains("client_key_pem"),
887            "missing-key error must name the missing field, got: {}",
888            err
889        );
890
891        let cfg = RemoteGitBackendConfig::new("http://localhost", "r").client_key_pem("/dev/null");
892        let err = RemoteGitBackend::new(cfg).unwrap_err();
893        assert!(
894            err.to_string().contains("client_cert_pem"),
895            "missing-cert error must name the missing field, got: {}",
896            err
897        );
898    }
899
900    #[test]
901    fn mtls_rejects_invalid_pem_blob() {
902        let tmp = tempfile::tempdir().unwrap();
903        let cert = tmp.path().join("cert.pem");
904        let key = tmp.path().join("key.pem");
905        std::fs::write(&cert, b"not a pem").unwrap();
906        std::fs::write(&key, b"also not a pem").unwrap();
907
908        let cfg = RemoteGitBackendConfig::new("http://localhost", "r")
909            .client_cert_pem(&cert)
910            .client_key_pem(&key);
911        let err = RemoteGitBackend::new(cfg).unwrap_err();
912        let msg = err.to_string();
913        assert!(
914            msg.contains("PEM"),
915            "PEM-parse failure must surface clearly, got: {}",
916            msg
917        );
918        assert!(
919            msg.contains(cert.to_str().unwrap()),
920            "error must include the cert path for debugging, got: {}",
921            msg
922        );
923    }
924
925    #[test]
926    fn mtls_accepts_self_signed_pair_from_rcgen() {
927        // rcgen produces a valid cert + PKCS#8 key pair; `reqwest::Identity`
928        // (rustls-tls backend) should accept the concatenated PEM blob.
929        let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
930            .expect("rcgen self-signed cert");
931        let tmp = tempfile::tempdir().unwrap();
932        let cert_path = tmp.path().join("client.cert.pem");
933        let key_path = tmp.path().join("client.key.pem");
934        std::fs::write(&cert_path, cert.cert.pem()).unwrap();
935        std::fs::write(&key_path, cert.key_pair.serialize_pem()).unwrap();
936
937        let cfg = RemoteGitBackendConfig::new("http://localhost", "r")
938            .bearer_token("t")
939            .client_cert_pem(&cert_path)
940            .client_key_pem(&key_path);
941        let backend = RemoteGitBackend::new(cfg)
942            .expect("valid rcgen-generated PEM pair must produce a backend");
943        // We cannot easily verify the identity is wired into the client without
944        // a live mTLS server; the assertion above (construction succeeds) is the
945        // contract — invalid material would have errored at `from_pem`.
946        assert_eq!(backend.base_url(), "http://localhost");
947    }
948
949    #[test]
950    fn safe_utf8_truncate_respects_boundaries() {
951        // ASCII path
952        assert_eq!(safe_utf8_truncate("hello", 3), 3);
953        assert_eq!(safe_utf8_truncate("hello", 100), 5);
954        // Multi-byte path: "héllo" — 'é' is 2 bytes (0xC3 0xA9)
955        let s = "héllo";
956        // Truncating at byte 2 lands inside 'é'; rounds down to 1 (after 'h')
957        assert_eq!(safe_utf8_truncate(s, 2), 1);
958        assert_eq!(safe_utf8_truncate(s, 3), 3);
959    }
960
961    #[tokio::test]
962    async fn status_happy_path() {
963        let (server, backend) = server_and_backend().await;
964        Mock::given(method("POST"))
965            .and(path("/v1/repos/test/git/status"))
966            .and(header("authorization", "Bearer test-token"))
967            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
968                "branch": "main",
969                "commit": "abc123",
970                "is_worktree": false,
971                "is_dirty": true,
972                "dirty_count": 3,
973            })))
974            .mount(&server)
975            .await;
976
977        let status = backend.status().await.unwrap();
978        assert_eq!(status.branch, "main");
979        assert_eq!(status.commit, "abc123");
980        assert!(status.is_dirty);
981        assert_eq!(status.dirty_count, 3);
982    }
983
984    #[tokio::test]
985    async fn log_respects_client_max_log_entries() {
986        let server = MockServer::start().await;
987        let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
988            .bearer_token("t")
989            .max_log_entries(5);
990        let backend = RemoteGitBackend::new(cfg).unwrap();
991
992        Mock::given(method("POST"))
993            .and(path("/v1/repos/test/git/log"))
994            .and(wiremock::matchers::body_json(json!({"max_count": 5})))
995            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
996                "commits": [
997                    {"id":"a","message":"m","author":"x","date":"d"}
998                ]
999            })))
1000            .mount(&server)
1001            .await;
1002
1003        // Client asks for 100, but the server should see the capped value.
1004        let commits = backend.log(100).await.unwrap();
1005        assert_eq!(commits.len(), 1);
1006        assert_eq!(commits[0].id, "a");
1007    }
1008
1009    #[tokio::test]
1010    async fn list_branches_maps_response() {
1011        let (server, backend) = server_and_backend().await;
1012        Mock::given(method("POST"))
1013            .and(path("/v1/repos/test/git/branches"))
1014            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1015                "branches": [
1016                    {"name":"main", "is_current":true},
1017                    {"name":"feat/x"}
1018                ]
1019            })))
1020            .mount(&server)
1021            .await;
1022
1023        let branches = backend.list_branches().await.unwrap();
1024        assert_eq!(branches.len(), 2);
1025        assert!(branches[0].is_current);
1026        assert!(!branches[1].is_current);
1027    }
1028
1029    #[tokio::test]
1030    async fn create_branch_succeeds_on_201() {
1031        let (server, backend) = server_and_backend().await;
1032        Mock::given(method("POST"))
1033            .and(path("/v1/repos/test/git/branches/create"))
1034            .and(wiremock::matchers::body_json(json!({
1035                "name":"feat/x","base":"main"
1036            })))
1037            .respond_with(ResponseTemplate::new(201).set_body_json(json!({})))
1038            .mount(&server)
1039            .await;
1040
1041        backend
1042            .create_branch(WorkspaceGitCreateBranchRequest {
1043                name: "feat/x".into(),
1044                base: "main".into(),
1045            })
1046            .await
1047            .unwrap();
1048    }
1049
1050    #[tokio::test]
1051    async fn create_branch_409_yields_remote_git_conflict() {
1052        let (server, backend) = server_and_backend().await;
1053        Mock::given(method("POST"))
1054            .and(path("/v1/repos/test/git/branches/create"))
1055            .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1056                "error":{"code":"BRANCH_EXISTS","message":"branch 'feat/x' already exists"}
1057            })))
1058            .mount(&server)
1059            .await;
1060
1061        let err = backend
1062            .create_branch(WorkspaceGitCreateBranchRequest {
1063                name: "feat/x".into(),
1064                base: "main".into(),
1065            })
1066            .await
1067            .unwrap_err();
1068        let conflict = err
1069            .downcast_ref::<RemoteGitConflict>()
1070            .expect("409 must downcast to RemoteGitConflict");
1071        assert_eq!(conflict.code, "BRANCH_EXISTS");
1072        assert!(conflict.message.contains("feat/x"));
1073    }
1074
1075    #[tokio::test]
1076    async fn checkout_returns_stdout() {
1077        let (server, backend) = server_and_backend().await;
1078        Mock::given(method("POST"))
1079            .and(path("/v1/repos/test/git/checkout"))
1080            .and(wiremock::matchers::body_json(json!({
1081                "refspec":"feat/x","force":false
1082            })))
1083            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1084                "stdout":"Switched to branch 'feat/x'"
1085            })))
1086            .mount(&server)
1087            .await;
1088
1089        let out = backend
1090            .checkout(WorkspaceGitCheckoutRequest {
1091                refspec: "feat/x".into(),
1092                force: false,
1093            })
1094            .await
1095            .unwrap();
1096        assert!(out.stdout.contains("feat/x"));
1097    }
1098
1099    #[tokio::test]
1100    async fn checkout_409_dirty_yields_conflict() {
1101        let (server, backend) = server_and_backend().await;
1102        Mock::given(method("POST"))
1103            .and(path("/v1/repos/test/git/checkout"))
1104            .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1105                "error":{"code":"WORKING_TREE_DIRTY","message":"please stash first"}
1106            })))
1107            .mount(&server)
1108            .await;
1109
1110        let err = backend
1111            .checkout(WorkspaceGitCheckoutRequest {
1112                refspec: "main".into(),
1113                force: false,
1114            })
1115            .await
1116            .unwrap_err();
1117        let c = err.downcast_ref::<RemoteGitConflict>().unwrap();
1118        assert_eq!(c.code, "WORKING_TREE_DIRTY");
1119    }
1120
1121    #[tokio::test]
1122    async fn diff_passes_target_through_and_surfaces_server_truncation() {
1123        let (server, backend) = server_and_backend().await;
1124        Mock::given(method("POST"))
1125            .and(path("/v1/repos/test/git/diff"))
1126            .and(wiremock::matchers::body_json(json!({"target":"main"})))
1127            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1128                "diff":"<huge diff>",
1129                "truncated": true
1130            })))
1131            .mount(&server)
1132            .await;
1133
1134        let diff = backend
1135            .diff(WorkspaceGitDiffRequest {
1136                target: Some("main".to_string()),
1137            })
1138            .await
1139            .unwrap();
1140        assert!(diff.contains("truncated by gitserver"));
1141    }
1142
1143    #[tokio::test]
1144    async fn diff_enforces_client_max_diff_bytes() {
1145        let server = MockServer::start().await;
1146        let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1147            .bearer_token("t")
1148            .max_diff_bytes(8);
1149        let backend = RemoteGitBackend::new(cfg).unwrap();
1150
1151        Mock::given(method("POST"))
1152            .and(path("/v1/repos/test/git/diff"))
1153            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1154                "diff":"AAAAAAAAAAAAAAAAAAAAAA",   // 22 bytes
1155                "truncated": false
1156            })))
1157            .mount(&server)
1158            .await;
1159
1160        let diff = backend
1161            .diff(WorkspaceGitDiffRequest { target: None })
1162            .await
1163            .unwrap();
1164        assert!(diff.contains("truncated by client max_diff_bytes"));
1165        // First 8 bytes preserved.
1166        assert!(diff.starts_with("AAAAAAAA"));
1167    }
1168
1169    /// Phase 6.2 OOM defence: the gitserver advertises a Content-Length far
1170    /// beyond what the client tolerates. The request must fail without
1171    /// consuming the body.
1172    ///
1173    /// `max_diff_bytes = 8` ⇒ `hard_cap = max(8 * 4, 64 KiB) = 64 KiB`.
1174    /// We respond with `Content-Length: 1 048 576` so the eager rejection
1175    /// path fires.
1176    #[tokio::test]
1177    async fn diff_rejects_oversized_content_length_upfront() {
1178        let server = MockServer::start().await;
1179        let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1180            .bearer_token("t")
1181            .max_diff_bytes(8);
1182        let backend = RemoteGitBackend::new(cfg).unwrap();
1183
1184        // 1 MiB body — far past the 64 KiB hard cap floor.
1185        let huge_body = vec![b'A'; 1024 * 1024];
1186        Mock::given(method("POST"))
1187            .and(path("/v1/repos/test/git/diff"))
1188            .respond_with(
1189                ResponseTemplate::new(200)
1190                    .insert_header("content-type", "application/json")
1191                    .set_body_bytes(huge_body),
1192            )
1193            .mount(&server)
1194            .await;
1195
1196        let err = backend
1197            .diff(WorkspaceGitDiffRequest { target: None })
1198            .await
1199            .expect_err("oversized body must be rejected");
1200        let msg = err.to_string();
1201        assert!(
1202            msg.contains("Content-Length") && msg.contains("exceeds client cap"),
1203            "expected eager Content-Length rejection, got: {}",
1204            msg
1205        );
1206    }
1207
1208    /// Phase 6.2 OOM defence layer 2: when Content-Length is absent or the
1209    /// server lies about it, the stream-bound accumulator must abort once
1210    /// the cap is exceeded. We use chunked transfer (no Content-Length) so
1211    /// the eager path doesn't fire.
1212    #[tokio::test]
1213    async fn diff_aborts_mid_stream_on_cap_exceeded() {
1214        let server = MockServer::start().await;
1215        let cfg = RemoteGitBackendConfig::new(server.uri(), "test")
1216            .bearer_token("t")
1217            .max_diff_bytes(8);
1218        let backend = RemoteGitBackend::new(cfg).unwrap();
1219
1220        // Body large enough to exceed the 64 KiB hard cap floor; chunked
1221        // transfer encoded so no Content-Length header is set.
1222        let big_body = vec![b'A'; 256 * 1024];
1223        Mock::given(method("POST"))
1224            .and(path("/v1/repos/test/git/diff"))
1225            .respond_with(
1226                ResponseTemplate::new(200)
1227                    .insert_header("transfer-encoding", "chunked")
1228                    .set_body_bytes(big_body),
1229            )
1230            .mount(&server)
1231            .await;
1232
1233        let err = backend
1234            .diff(WorkspaceGitDiffRequest { target: None })
1235            .await
1236            .expect_err("oversized streamed body must be rejected");
1237        let msg = err.to_string();
1238        // Either the eager path (if wiremock surfaces a Content-Length) or
1239        // the stream-abort path fires; both are valid defences.
1240        assert!(
1241            msg.contains("exceeds client cap")
1242                || msg.contains("exceeded client cap")
1243                || msg.contains("Content-Length"),
1244            "expected oversize rejection, got: {}",
1245            msg
1246        );
1247    }
1248
1249    #[tokio::test]
1250    async fn list_remotes_defaults_direction() {
1251        let (server, backend) = server_and_backend().await;
1252        Mock::given(method("POST"))
1253            .and(path("/v1/repos/test/git/remotes"))
1254            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1255                "remotes":[{"name":"origin","url":"git@x:y.git"}]
1256            })))
1257            .mount(&server)
1258            .await;
1259
1260        let rs = backend.list_remotes().await.unwrap();
1261        assert_eq!(rs.len(), 1);
1262        assert_eq!(rs[0].direction, "fetch");
1263    }
1264
1265    #[tokio::test]
1266    async fn is_repository_returns_bool() {
1267        let (server, backend) = server_and_backend().await;
1268        Mock::given(method("POST"))
1269            .and(path("/v1/repos/test/git/exists"))
1270            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1271                "is_repository": true
1272            })))
1273            .mount(&server)
1274            .await;
1275
1276        assert!(backend.is_repository().await.unwrap());
1277    }
1278
1279    #[tokio::test]
1280    async fn list_stashes_maps_response() {
1281        let (server, backend) = server_and_backend().await;
1282        Mock::given(method("POST"))
1283            .and(path("/v1/repos/test/git/stashes"))
1284            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1285                "stashes":[{"index":0,"message":"WIP"}]
1286            })))
1287            .mount(&server)
1288            .await;
1289
1290        let s = backend.list_stashes().await.unwrap();
1291        assert_eq!(s.len(), 1);
1292        assert_eq!(s[0].message, "WIP");
1293    }
1294
1295    #[tokio::test]
1296    async fn stash_create_409_nothing_to_stash() {
1297        let (server, backend) = server_and_backend().await;
1298        Mock::given(method("POST"))
1299            .and(path("/v1/repos/test/git/stashes/create"))
1300            .respond_with(ResponseTemplate::new(409).set_body_json(json!({
1301                "error":{"code":"NOTHING_TO_STASH","message":"clean tree"}
1302            })))
1303            .mount(&server)
1304            .await;
1305
1306        let err = backend
1307            .stash(WorkspaceGitStashRequest {
1308                message: None,
1309                include_untracked: false,
1310            })
1311            .await
1312            .unwrap_err();
1313        let c = err.downcast_ref::<RemoteGitConflict>().unwrap();
1314        assert_eq!(c.code, "NOTHING_TO_STASH");
1315    }
1316
1317    #[tokio::test]
1318    async fn not_found_404_is_generic_anyhow() {
1319        let (server, backend) = server_and_backend().await;
1320        Mock::given(method("POST"))
1321            .and(path("/v1/repos/test/git/status"))
1322            .respond_with(ResponseTemplate::new(404).set_body_json(json!({
1323                "error":{"code":"REPO_NOT_FOUND","message":"unknown repo"}
1324            })))
1325            .mount(&server)
1326            .await;
1327
1328        let err = backend.status().await.unwrap_err();
1329        assert!(err.to_string().contains("not found"), "msg: {}", err);
1330        assert!(err.downcast_ref::<RemoteGitConflict>().is_none());
1331    }
1332
1333    #[tokio::test]
1334    async fn auth_failure_401_is_generic_anyhow() {
1335        let (server, backend) = server_and_backend().await;
1336        Mock::given(method("POST"))
1337            .and(path("/v1/repos/test/git/status"))
1338            .respond_with(ResponseTemplate::new(401).set_body_json(json!({
1339                "error":{"code":"INVALID_TOKEN","message":"bad bearer"}
1340            })))
1341            .mount(&server)
1342            .await;
1343
1344        let err = backend.status().await.unwrap_err();
1345        assert!(err.to_string().contains("auth failed"), "msg: {}", err);
1346        assert!(err.downcast_ref::<RemoteGitConflict>().is_none());
1347    }
1348
1349    #[tokio::test]
1350    async fn server_500_is_generic_anyhow() {
1351        let (server, backend) = server_and_backend().await;
1352        Mock::given(method("POST"))
1353            .and(path("/v1/repos/test/git/status"))
1354            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
1355            .mount(&server)
1356            .await;
1357
1358        let err = backend.status().await.unwrap_err();
1359        assert!(err.to_string().contains("server error"), "msg: {}", err);
1360    }
1361
1362    #[tokio::test]
1363    async fn non_json_error_body_falls_back_to_http_code() {
1364        let (server, backend) = server_and_backend().await;
1365        Mock::given(method("POST"))
1366            .and(path("/v1/repos/test/git/status"))
1367            .respond_with(ResponseTemplate::new(409).set_body_string("not json"))
1368            .mount(&server)
1369            .await;
1370
1371        let err = backend.status().await.unwrap_err();
1372        // 409 always yields a conflict — even when the body is opaque, we
1373        // surface it so callers can detect it; the code falls back to
1374        // HTTP_409.
1375        let c = err
1376            .downcast_ref::<RemoteGitConflict>()
1377            .expect("409 must yield conflict regardless of body shape");
1378        assert_eq!(c.code, "HTTP_409");
1379        assert_eq!(c.message, "not json");
1380    }
1381
1382    #[tokio::test]
1383    async fn with_remote_git_wires_git_and_stash() {
1384        let services = super::super::WorkspaceServices::local(std::env::temp_dir());
1385        let upgraded = services
1386            .with_remote_git(RemoteGitBackendConfig::new("http://localhost", "r").bearer_token("t"))
1387            .unwrap();
1388        assert!(upgraded.git().is_some());
1389        assert!(upgraded.git_stash().is_some());
1390        // Worktree provider intentionally dropped on remote-git workspaces —
1391        // worktrees do not have a remote analogue (see RFC §8).
1392        assert!(upgraded.git_worktree().is_none());
1393        assert!(upgraded.capabilities().git);
1394    }
1395
1396    /// Regression test for Phase 6.1 field-loss bug.
1397    ///
1398    /// `with_remote_git` previously rebuilt `WorkspaceServices` via the
1399    /// builder, which silently dropped `local_root` (and would silently
1400    /// drop any future field). After the fix it goes through
1401    /// `with_git_provider`, which uses an explicit struct literal — the
1402    /// compiler now forces every field to be addressed.
1403    #[tokio::test]
1404    async fn with_remote_git_preserves_local_root_and_unrelated_capabilities() {
1405        let temp = tempfile::tempdir().unwrap();
1406        let base = super::super::WorkspaceServices::local(temp.path());
1407        assert!(
1408            base.local_root().is_some(),
1409            "precondition: local() must set local_root"
1410        );
1411        assert!(
1412            base.command_runner().is_some(),
1413            "precondition: local() must wire bash runner"
1414        );
1415        let base_root = base.local_root().map(|p| p.to_path_buf());
1416
1417        let upgraded = base
1418            .with_remote_git(RemoteGitBackendConfig::new("http://localhost", "r").bearer_token("t"))
1419            .unwrap();
1420
1421        // The git provider IS replaced.
1422        assert!(upgraded.git().is_some());
1423        assert!(upgraded.capabilities().git);
1424        // Unrelated capabilities survive.
1425        assert_eq!(
1426            upgraded.local_root().map(|p| p.to_path_buf()),
1427            base_root,
1428            "local_root must survive with_remote_git"
1429        );
1430        assert!(
1431            upgraded.command_runner().is_some(),
1432            "command_runner must survive with_remote_git"
1433        );
1434        assert!(
1435            upgraded.search().is_some(),
1436            "search provider must survive with_remote_git"
1437        );
1438        // But worktree is intentionally severed alongside the git swap.
1439        assert!(upgraded.git_worktree().is_none());
1440    }
1441
1442    /// End-to-end test: drive the built-in `git` tool against a wiremock-backed
1443    /// gitserver. Exercises the full path `git tool → WorkspaceGit (remote) →
1444    /// HTTP → wiremock → JSON → DTO → WorkspaceGitStatus → tool output`.
1445    ///
1446    /// This is the contract test for Phase 4.2: if any layer breaks, this
1447    /// test fails. Per-method unit tests above isolate the HTTP layer; this
1448    /// one proves the tool wiring actually works through a real ToolContext.
1449    #[tokio::test]
1450    async fn git_tool_status_works_through_remote_backend() {
1451        use crate::tools::{Tool, ToolContext};
1452
1453        let server = MockServer::start().await;
1454        // `git` tool probes `is_repository` before dispatching.
1455        Mock::given(method("POST"))
1456            .and(path("/v1/repos/u1/s1/git/exists"))
1457            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"is_repository": true})))
1458            .mount(&server)
1459            .await;
1460        Mock::given(method("POST"))
1461            .and(path("/v1/repos/u1/s1/git/status"))
1462            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1463                "branch":"main",
1464                "commit":"deadbeef",
1465                "is_worktree": false,
1466                "is_dirty": false,
1467                "dirty_count": 0,
1468            })))
1469            .mount(&server)
1470            .await;
1471
1472        let base = super::super::WorkspaceServices::local(std::env::temp_dir());
1473        let services = base
1474            .with_remote_git(RemoteGitBackendConfig::new(server.uri(), "u1/s1").bearer_token("tok"))
1475            .unwrap();
1476
1477        let tool = crate::tools::builtin::git::GitTool;
1478        let ctx = ToolContext::new(std::env::temp_dir()).with_workspace_services(services);
1479
1480        let result = tool
1481            .execute(&json!({"command": "status"}), &ctx)
1482            .await
1483            .unwrap();
1484        assert!(result.success, "tool output: {}", result.content);
1485        assert!(
1486            result.content.contains("main"),
1487            "expected branch name in output: {}",
1488            result.content
1489        );
1490        assert!(
1491            result.content.contains("deadbeef"),
1492            "expected commit hash in output: {}",
1493            result.content
1494        );
1495        assert!(
1496            result.content.contains("clean"),
1497            "expected clean status in output: {}",
1498            result.content
1499        );
1500    }
1501}