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)]
840#[path = "remote_git/tests.rs"]
841mod tests;