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 =
165            Client::builder().timeout(config.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT));
166
167        // mTLS: both files must be present, otherwise fail closed.
168        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    /// Base URL the client is configured to use (no trailing slash).
205    pub fn base_url(&self) -> &str {
206        &self.base_url
207    }
208
209    /// Opaque repository identifier passed in every request URL.
210    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    /// Like [`Self::post_json`] but with a hard cap on the streamed response
291    /// body in bytes, intended for endpoints that can legitimately return
292    /// large payloads (`diff`).
293    ///
294    /// Two layers of defence:
295    /// 1. If the server sends a `Content-Length` greater than `max_bytes`,
296    ///    the request is rejected before any body is consumed.
297    /// 2. Otherwise the body is streamed; once the accumulated buffer
298    ///    exceeds `max_bytes`, the stream is dropped and the call returns
299    ///    an error. Memory is bounded at `max_bytes + one chunk`.
300    ///
301    /// Used by [`WorkspaceGit::diff`]; protects against a misbehaving
302    /// gitserver that ignores the client's soft `max_diff_bytes`.
303    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        // Layer 1: eager rejection on advertised oversized body.
334        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        // Layer 2: stream-bound accumulation.
355        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        // Two-layered defence against a misbehaving gitserver:
589        //
590        // * **Hard memory cap** = `max_diff_bytes * 4` (floor 64 KiB). The
591        //   request streams the body and aborts once this is exceeded, so a
592        //   server returning a 1 GiB diff never gets fully buffered. We
593        //   allow 4× slack over the soft cap so legitimate-but-large diffs
594        //   reach the parser and can be display-truncated below.
595        // * **Soft display cap** = `max_diff_bytes`. Applied after JSON
596        //   decode: the diff text we hand back to the tool is shortened to
597        //   this many bytes (UTF-8-safe) so callers see a useful preview
598        //   without the model context bloating.
599        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
676/// Read the mTLS cert + key PEM files and assemble a `reqwest::Identity`.
677///
678/// `reqwest::Identity::from_pem` (with the `rustls-tls` backend) wants a
679/// single PEM blob containing the certificate chain followed by the
680/// private key. We concatenate the two files with a newline separator —
681/// stray trailing newlines in either file are tolerated by the PEM
682/// parser. Errors at every step (file I/O, PEM parsing) are mapped to
683/// `anyhow` with the source path included so misconfigurations surface
684/// clearly.
685fn 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
721/// Truncate `s` to at most `max_bytes`, rounding down to the nearest UTF-8
722/// character boundary to keep the result a valid `&str`.
723fn 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
734/// Map a non-2xx response to an `anyhow::Error`, attaching a typed
735/// [`RemoteGitConflict`] when the server returned a recoverable code under
736/// 409 or 422.
737///
738/// Synchronous and takes the pre-fetched response body so it can be shared
739/// between callers that hold a `reqwest::Response` and callers that have
740/// already streamed the body into a `Vec<u8>` (for size-capped paths).
741fn 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
787/// Emit a structured `tracing::debug!` event for a single gitserver call.
788///
789/// Mirrors the metering shape used by `S3WorkspaceBackend::emit_s3_call_event`
790/// so a single subscriber can meter both backends. Fields:
791///
792/// | Field         | Meaning                                          |
793/// |---------------|--------------------------------------------------|
794/// | `op`          | gitserver op (`status`, `log`, `diff`, ...)      |
795/// | `repo_id`     | opaque repo identifier                           |
796/// | `status`      | HTTP status code (when the request reached server) |
797/// | `outcome`     | `ok` \| `error`                                   |
798/// | `bytes`       | response body length, when known                  |
799/// | `duration_ms` | wall-clock                                       |
800fn 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    /// Attach a remote git provider to an existing [`WorkspaceServices`].
820    ///
821    /// Returns a new `Arc<WorkspaceServices>` with `git` and `git_stash`
822    /// wired to the remote backend. The original `WorkspaceServices` is
823    /// not mutated. `git_worktree` is intentionally reset to `None` —
824    /// worktrees are a local-filesystem concept that does not map cleanly
825    /// onto a remote service (see RFC §8). All other fields — including
826    /// `local_root`, the command runner, the search provider, the
827    /// optional `file_system_ext` (S3 CAS), and `operation_timeout` — are
828    /// preserved verbatim via
829    /// [`super::WorkspaceServices::with_git_provider`].
830    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        // Trailing slash on base_url is stripped.
869        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        // rcgen produces a valid cert + PKCS#8 key pair; `reqwest::Identity`
927        // (rustls-tls backend) should accept the concatenated PEM blob.
928        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        // We cannot easily verify the identity is wired into the client without
943        // a live mTLS server; the assertion above (construction succeeds) is the
944        // contract — invalid material would have errored at `from_pem`.
945        assert_eq!(backend.base_url(), "http://localhost");
946    }
947
948    #[test]
949    fn safe_utf8_truncate_respects_boundaries() {
950        // ASCII path
951        assert_eq!(safe_utf8_truncate("hello", 3), 3);
952        assert_eq!(safe_utf8_truncate("hello", 100), 5);
953        // Multi-byte path: "héllo" — 'é' is 2 bytes (0xC3 0xA9)
954        let s = "héllo";
955        // Truncating at byte 2 lands inside 'é'; rounds down to 1 (after 'h')
956        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        // Client asks for 100, but the server should see the capped value.
1003        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",   // 22 bytes
1154                "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        // First 8 bytes preserved.
1165        assert!(diff.starts_with("AAAAAAAA"));
1166    }
1167
1168    /// Phase 6.2 OOM defence: the gitserver advertises a Content-Length far
1169    /// beyond what the client tolerates. The request must fail without
1170    /// consuming the body.
1171    ///
1172    /// `max_diff_bytes = 8` ⇒ `hard_cap = max(8 * 4, 64 KiB) = 64 KiB`.
1173    /// We respond with `Content-Length: 1 048 576` so the eager rejection
1174    /// path fires.
1175    #[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        // 1 MiB body — far past the 64 KiB hard cap floor.
1184        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    /// Phase 6.2 OOM defence layer 2: when Content-Length is absent or the
1208    /// server lies about it, the stream-bound accumulator must abort once
1209    /// the cap is exceeded. We use chunked transfer (no Content-Length) so
1210    /// the eager path doesn't fire.
1211    #[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        // Body large enough to exceed the 64 KiB hard cap floor; chunked
1220        // transfer encoded so no Content-Length header is set.
1221        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        // Either the eager path (if wiremock surfaces a Content-Length) or
1238        // the stream-abort path fires; both are valid defences.
1239        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        // 409 always yields a conflict — even when the body is opaque, we
1372        // surface it so callers can detect it; the code falls back to
1373        // HTTP_409.
1374        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        // Worktree provider intentionally dropped on remote-git workspaces —
1390        // worktrees do not have a remote analogue (see RFC §8).
1391        assert!(upgraded.git_worktree().is_none());
1392        assert!(upgraded.capabilities().git);
1393    }
1394
1395    /// Regression test for Phase 6.1 field-loss bug.
1396    ///
1397    /// `with_remote_git` previously rebuilt `WorkspaceServices` via the
1398    /// builder, which silently dropped `local_root` (and would silently
1399    /// drop any future field). After the fix it goes through
1400    /// `with_git_provider`, which uses an explicit struct literal — the
1401    /// compiler now forces every field to be addressed.
1402    #[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        // The git provider IS replaced.
1421        assert!(upgraded.git().is_some());
1422        assert!(upgraded.capabilities().git);
1423        // Unrelated capabilities survive.
1424        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        // But worktree is intentionally severed alongside the git swap.
1438        assert!(upgraded.git_worktree().is_none());
1439    }
1440
1441    /// End-to-end test: drive the built-in `git` tool against a wiremock-backed
1442    /// gitserver. Exercises the full path `git tool → WorkspaceGit (remote) →
1443    /// HTTP → wiremock → JSON → DTO → WorkspaceGitStatus → tool output`.
1444    ///
1445    /// This is the contract test for Phase 4.2: if any layer breaks, this
1446    /// test fails. Per-method unit tests above isolate the HTTP layer; this
1447    /// one proves the tool wiring actually works through a real ToolContext.
1448    #[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        // `git` tool probes `is_repository` before dispatching.
1454        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}