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;
58const MAX_REMOTE_JSON_BYTES: u64 = 4 * 1024 * 1024;
59
60/// Configuration for a [`RemoteGitBackend`].
61///
62/// `base_url` should not have a trailing slash; the client constructs
63/// `{base_url}/v1/repos/{repo_id}/git/{op}` per the RFC.
64#[derive(Debug, Clone)]
65pub struct RemoteGitBackendConfig {
66    pub base_url: String,
67    pub repo_id: String,
68    pub bearer_token: Option<String>,
69    /// mTLS client certificate path (PEM). When set together with
70    /// `client_key_pem`, the backend reads both files at construction,
71    /// concatenates them, and configures `reqwest::Identity::from_pem`
72    /// on the HTTP client. Setting only one of the pair errors at
73    /// construction.
74    pub client_cert_pem: Option<PathBuf>,
75    /// mTLS client private key path (PEM). See `client_cert_pem`. The key
76    /// must be in PKCS#8 PEM format for the `rustls-tls` backend.
77    pub client_key_pem: Option<PathBuf>,
78    pub request_timeout: Option<Duration>,
79    pub max_diff_bytes: Option<u64>,
80    pub max_log_entries: Option<usize>,
81}
82
83impl RemoteGitBackendConfig {
84    pub fn new(base_url: impl Into<String>, repo_id: impl Into<String>) -> Self {
85        Self {
86            base_url: base_url.into(),
87            repo_id: repo_id.into(),
88            bearer_token: None,
89            client_cert_pem: None,
90            client_key_pem: None,
91            request_timeout: None,
92            max_diff_bytes: None,
93            max_log_entries: None,
94        }
95    }
96
97    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
98        self.bearer_token = Some(token.into());
99        self
100    }
101
102    pub fn request_timeout(mut self, timeout: Duration) -> Self {
103        self.request_timeout = Some(timeout);
104        self
105    }
106
107    pub fn max_diff_bytes(mut self, bytes: u64) -> Self {
108        self.max_diff_bytes = Some(bytes);
109        self
110    }
111
112    pub fn max_log_entries(mut self, n: usize) -> Self {
113        self.max_log_entries = Some(n);
114        self
115    }
116
117    pub fn client_cert_pem(mut self, path: impl Into<PathBuf>) -> Self {
118        self.client_cert_pem = Some(path.into());
119        self
120    }
121
122    pub fn client_key_pem(mut self, path: impl Into<PathBuf>) -> Self {
123        self.client_key_pem = Some(path.into());
124        self
125    }
126}
127
128/// Error returned for HTTP 409 / 422 responses that carry a recoverable
129/// failure code. Tools downcast with `anyhow::Error::downcast_ref` to react
130/// — for example, retrying after a `WORKING_TREE_DIRTY` by stashing first.
131#[derive(Debug, Clone, thiserror::Error)]
132#[error("remote git conflict: {code}: {message}")]
133pub struct RemoteGitConflict {
134    pub code: String,
135    pub message: String,
136}
137
138/// Client for a remote `gitserver`. See module docs / RFC for the protocol.
139#[derive(Debug, Clone)]
140pub struct RemoteGitBackend {
141    http: Client,
142    base_url: String,
143    repo_id: String,
144    bearer_token: Option<String>,
145    max_diff_bytes: u64,
146    max_log_entries: usize,
147}
148
149impl RemoteGitBackend {
150    /// Build a backend from declarative configuration.
151    pub fn new(config: RemoteGitBackendConfig) -> Result<Arc<Self>> {
152        if config
153            .bearer_token
154            .as_deref()
155            .map(str::is_empty)
156            .unwrap_or(true)
157            && config.client_cert_pem.is_none()
158        {
159            tracing::warn!(
160                "RemoteGitBackend constructed without bearer token or mTLS; \
161                 this is only safe on a trusted localhost gitserver"
162            );
163        }
164
165        let mut builder = Client::builder()
166            .no_proxy()
167            .timeout(config.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT));
168
169        // mTLS: both files must be present, otherwise fail closed.
170        match (
171            config.client_cert_pem.as_deref(),
172            config.client_key_pem.as_deref(),
173        ) {
174            (Some(cert_path), Some(key_path)) => {
175                let identity = load_mtls_identity(cert_path, key_path)?;
176                builder = builder.identity(identity);
177            }
178            (Some(_), None) => {
179                return Err(anyhow!(
180                    "client_cert_pem was set without client_key_pem; both must be provided for mTLS"
181                ));
182            }
183            (None, Some(_)) => {
184                return Err(anyhow!(
185                    "client_key_pem was set without client_cert_pem; both must be provided for mTLS"
186                ));
187            }
188            (None, None) => {}
189        }
190
191        let http = builder
192            .build()
193            .map_err(|e| anyhow!("failed to build reqwest client: {}", e))?;
194
195        let base_url = config.base_url.trim_end_matches('/').to_string();
196        Ok(Arc::new(Self {
197            http,
198            base_url,
199            repo_id: config.repo_id,
200            bearer_token: config.bearer_token,
201            max_diff_bytes: config.max_diff_bytes.unwrap_or(DEFAULT_MAX_DIFF_BYTES),
202            max_log_entries: config.max_log_entries.unwrap_or(DEFAULT_MAX_LOG_ENTRIES),
203        }))
204    }
205
206    /// Base URL the client is configured to use (no trailing slash).
207    pub fn base_url(&self) -> &str {
208        &self.base_url
209    }
210
211    /// Opaque repository identifier passed in every request URL.
212    pub fn repo_id(&self) -> &str {
213        &self.repo_id
214    }
215
216    pub fn max_diff_bytes(&self) -> u64 {
217        self.max_diff_bytes
218    }
219
220    pub fn max_log_entries(&self) -> usize {
221        self.max_log_entries
222    }
223
224    fn endpoint(&self, op: &str) -> String {
225        format!("{}/v1/repos/{}/git/{}", self.base_url, self.repo_id, op)
226    }
227
228    async fn post_json<Req, Resp>(&self, op: &'static str, body: &Req) -> Result<Resp>
229    where
230        Req: Serialize + ?Sized,
231        Resp: for<'de> Deserialize<'de>,
232    {
233        let url = self.endpoint(op);
234        let mut req = self.http.post(&url).json(body);
235        if let Some(token) = self.bearer_token.as_deref() {
236            if !token.is_empty() {
237                req = req.bearer_auth(token);
238            }
239        }
240
241        let start = std::time::Instant::now();
242        let send_result = req.send().await;
243        let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
244        let ok = matches!(send_result.as_ref(), Ok(r) if r.status().is_success());
245        emit_remote_git_event(op, &self.repo_id, status_code, ok, start.elapsed(), None);
246
247        let resp =
248            send_result.map_err(|e| anyhow!("remote git call '{}' transport error: {}", op, e))?;
249
250        let status = resp.status();
251        let body = read_response_bytes_bounded(resp, MAX_REMOTE_JSON_BYTES).await?;
252        if status.is_success() {
253            let parsed = serde_json::from_slice::<Resp>(&body)
254                .map_err(|e| anyhow!("remote git '{}' response body decode error: {}", op, e))?;
255            return Ok(parsed);
256        }
257
258        let body_text = String::from_utf8_lossy(&body).into_owned();
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 = read_response_bytes_bounded(resp, MAX_REMOTE_JSON_BYTES).await?;
288        let body_text = String::from_utf8_lossy(&body).into_owned();
289        Err(map_error_response(op, status, &body_text))
290    }
291
292    /// Like [`Self::post_json`] but with a hard cap on the streamed response
293    /// body in bytes, intended for endpoints that can legitimately return
294    /// large payloads (`diff`).
295    ///
296    /// Two layers of defence:
297    /// 1. If the server sends a `Content-Length` greater than `max_bytes`,
298    ///    the request is rejected before any body is consumed.
299    /// 2. Otherwise the body is streamed; once the accumulated buffer
300    ///    exceeds `max_bytes`, the stream is dropped and the call returns
301    ///    an error. Memory is bounded at `max_bytes + one chunk`.
302    ///
303    /// Used by [`WorkspaceGit::diff`]; protects against a misbehaving
304    /// gitserver that ignores the client's soft `max_diff_bytes`.
305    async fn post_streamed<Req>(
306        &self,
307        op: &'static str,
308        body: &Req,
309        max_bytes: u64,
310    ) -> Result<Vec<u8>>
311    where
312        Req: Serialize + ?Sized,
313    {
314        use futures::StreamExt;
315
316        let url = self.endpoint(op);
317        let mut req = self.http.post(&url).json(body);
318        if let Some(token) = self.bearer_token.as_deref() {
319            if !token.is_empty() {
320                req = req.bearer_auth(token);
321            }
322        }
323
324        let start = std::time::Instant::now();
325        let send_result = req.send().await;
326        let status_code = send_result.as_ref().ok().map(|r| r.status().as_u16());
327        let resp = match send_result {
328            Ok(r) => r,
329            Err(e) => {
330                emit_remote_git_event(op, &self.repo_id, status_code, false, start.elapsed(), None);
331                return Err(anyhow!("remote git call '{}' transport error: {}", op, e));
332            }
333        };
334
335        // Layer 1: eager rejection on advertised oversized body.
336        if let Some(len) = resp.content_length() {
337            if len > max_bytes {
338                emit_remote_git_event(
339                    op,
340                    &self.repo_id,
341                    status_code,
342                    false,
343                    start.elapsed(),
344                    Some(len),
345                );
346                return Err(anyhow!(
347                    "remote git '{}' Content-Length {} exceeds client cap {} bytes; \
348                     refusing to download. Raise max_diff_bytes if the body is legitimate.",
349                    op,
350                    len,
351                    max_bytes
352                ));
353            }
354        }
355
356        // Layer 2: stream-bound accumulation.
357        let status = resp.status();
358        let mut stream = resp.bytes_stream();
359        let mut buf: Vec<u8> = Vec::new();
360        while let Some(chunk) = stream.next().await {
361            let chunk = chunk.map_err(|e| anyhow!("remote git '{}' stream error: {}", op, e))?;
362            if (buf.len() as u64).saturating_add(chunk.len() as u64) > max_bytes {
363                emit_remote_git_event(
364                    op,
365                    &self.repo_id,
366                    status_code,
367                    false,
368                    start.elapsed(),
369                    Some(buf.len() as u64),
370                );
371                return Err(anyhow!(
372                    "remote git '{}' response body exceeded client cap {} bytes mid-stream; \
373                     aborting",
374                    op,
375                    max_bytes
376                ));
377            }
378            buf.extend_from_slice(&chunk);
379        }
380
381        emit_remote_git_event(
382            op,
383            &self.repo_id,
384            status_code,
385            status.is_success(),
386            start.elapsed(),
387            Some(buf.len() as u64),
388        );
389
390        if !status.is_success() {
391            let body_text = String::from_utf8_lossy(&buf).into_owned();
392            return Err(map_error_response(op, status, &body_text));
393        }
394        Ok(buf)
395    }
396}
397
398/// Read a non-streaming JSON/error response without allowing an untrusted
399/// remote Git server to allocate an unbounded body buffer.
400async fn read_response_bytes_bounded(
401    response: reqwest::Response,
402    max_bytes: u64,
403) -> Result<Vec<u8>> {
404    use futures::StreamExt;
405
406    if response
407        .content_length()
408        .is_some_and(|length| length > max_bytes)
409    {
410        return Err(anyhow!(
411            "remote git response Content-Length exceeds client cap {} bytes",
412            max_bytes
413        ));
414    }
415    let mut stream = response.bytes_stream();
416    let mut body = Vec::new();
417    while let Some(chunk) = stream.next().await {
418        let chunk = chunk.map_err(|error| anyhow!("remote git response stream error: {error}"))?;
419        if body.len() as u64 + chunk.len() as u64 > max_bytes {
420            return Err(anyhow!(
421                "remote git response body exceeded client cap {} bytes",
422                max_bytes
423            ));
424        }
425        body.extend_from_slice(&chunk);
426    }
427    Ok(body)
428}
429
430#[derive(Serialize)]
431struct EmptyReq;
432
433#[derive(Deserialize)]
434struct StatusResp {
435    branch: String,
436    commit: String,
437    #[serde(default)]
438    is_worktree: bool,
439    #[serde(default)]
440    is_dirty: bool,
441    #[serde(default)]
442    dirty_count: usize,
443}
444
445#[derive(Serialize)]
446struct LogReq {
447    max_count: usize,
448}
449
450#[derive(Deserialize)]
451struct LogResp {
452    commits: Vec<CommitDto>,
453}
454
455#[derive(Deserialize)]
456struct CommitDto {
457    id: String,
458    message: String,
459    author: String,
460    date: String,
461}
462
463#[derive(Deserialize)]
464struct BranchesResp {
465    branches: Vec<BranchDto>,
466}
467
468#[derive(Deserialize)]
469struct BranchDto {
470    name: String,
471    #[serde(default)]
472    is_current: bool,
473}
474
475#[derive(Serialize)]
476struct CreateBranchReq<'a> {
477    name: &'a str,
478    base: &'a str,
479}
480
481#[derive(Serialize)]
482struct CheckoutReq<'a> {
483    refspec: &'a str,
484    force: bool,
485}
486
487#[derive(Deserialize)]
488struct CheckoutResp {
489    #[serde(default)]
490    stdout: String,
491}
492
493#[derive(Serialize)]
494struct DiffReq<'a> {
495    target: Option<&'a str>,
496}
497
498#[derive(Deserialize)]
499struct DiffResp {
500    diff: String,
501    #[serde(default)]
502    truncated: bool,
503}
504
505#[derive(Deserialize)]
506struct RemotesResp {
507    remotes: Vec<RemoteDto>,
508}
509
510#[derive(Deserialize)]
511struct RemoteDto {
512    name: String,
513    url: String,
514    #[serde(default = "default_direction")]
515    direction: String,
516}
517
518fn default_direction() -> String {
519    "fetch".to_string()
520}
521
522#[derive(Deserialize)]
523struct ExistsResp {
524    #[serde(default)]
525    is_repository: bool,
526}
527
528#[derive(Deserialize)]
529struct StashesResp {
530    stashes: Vec<StashDto>,
531}
532
533#[derive(Deserialize)]
534struct StashDto {
535    index: usize,
536    #[serde(default)]
537    message: String,
538}
539
540#[derive(Serialize)]
541struct StashCreateReq {
542    #[serde(skip_serializing_if = "Option::is_none")]
543    message: Option<String>,
544    include_untracked: bool,
545}
546
547#[async_trait]
548impl WorkspaceGit for RemoteGitBackend {
549    async fn is_repository(&self) -> Result<bool> {
550        let resp: ExistsResp = self.post_json("exists", &EmptyReq).await?;
551        Ok(resp.is_repository)
552    }
553
554    async fn status(&self) -> Result<WorkspaceGitStatus> {
555        let resp: StatusResp = self.post_json("status", &EmptyReq).await?;
556        Ok(WorkspaceGitStatus {
557            branch: resp.branch,
558            commit: resp.commit,
559            is_worktree: resp.is_worktree,
560            is_dirty: resp.is_dirty,
561            dirty_count: resp.dirty_count,
562        })
563    }
564
565    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
566        let capped = max_count.min(self.max_log_entries);
567        let resp: LogResp = self.post_json("log", &LogReq { max_count: capped }).await?;
568        Ok(resp
569            .commits
570            .into_iter()
571            .map(|c| WorkspaceGitCommit {
572                id: c.id,
573                message: c.message,
574                author: c.author,
575                date: c.date,
576            })
577            .collect())
578    }
579
580    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
581        let resp: BranchesResp = self.post_json("branches", &EmptyReq).await?;
582        Ok(resp
583            .branches
584            .into_iter()
585            .map(|b| WorkspaceGitBranch {
586                name: b.name,
587                is_current: b.is_current,
588            })
589            .collect())
590    }
591
592    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
593        self.post_unit(
594            "branches/create",
595            &CreateBranchReq {
596                name: &request.name,
597                base: &request.base,
598            },
599        )
600        .await
601    }
602
603    async fn checkout(
604        &self,
605        request: WorkspaceGitCheckoutRequest,
606    ) -> Result<WorkspaceGitCheckoutOutput> {
607        let resp: CheckoutResp = self
608            .post_json(
609                "checkout",
610                &CheckoutReq {
611                    refspec: &request.refspec,
612                    force: request.force,
613                },
614            )
615            .await?;
616        Ok(WorkspaceGitCheckoutOutput {
617            stdout: resp.stdout,
618        })
619    }
620
621    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
622        // Two-layered defence against a misbehaving gitserver:
623        //
624        // * **Hard memory cap** = `max_diff_bytes * 4` (floor 64 KiB). The
625        //   request streams the body and aborts once this is exceeded, so a
626        //   server returning a 1 GiB diff never gets fully buffered. We
627        //   allow 4× slack over the soft cap so legitimate-but-large diffs
628        //   reach the parser and can be display-truncated below.
629        // * **Soft display cap** = `max_diff_bytes`. Applied after JSON
630        //   decode: the diff text we hand back to the tool is shortened to
631        //   this many bytes (UTF-8-safe) so callers see a useful preview
632        //   without the model context bloating.
633        const DIFF_HARD_CAP_FLOOR: u64 = 64 * 1024;
634        let hard_cap = self
635            .max_diff_bytes
636            .saturating_mul(4)
637            .max(DIFF_HARD_CAP_FLOOR);
638
639        let bytes = self
640            .post_streamed(
641                "diff",
642                &DiffReq {
643                    target: request.target.as_deref(),
644                },
645                hard_cap,
646            )
647            .await?;
648        let resp: DiffResp = serde_json::from_slice(&bytes)
649            .map_err(|e| anyhow!("remote git 'diff' response body decode error: {}", e))?;
650
651        if (resp.diff.len() as u64) > self.max_diff_bytes {
652            tracing::debug!(
653                "remote git diff body {} bytes exceeds max_diff_bytes {} — \
654                 client-side display truncation",
655                resp.diff.len(),
656                self.max_diff_bytes
657            );
658            let cap = self.max_diff_bytes as usize;
659            let mut trimmed = resp.diff;
660            trimmed.truncate(safe_utf8_truncate(&trimmed, cap));
661            trimmed.push_str("\n... [truncated by client max_diff_bytes]\n");
662            return Ok(trimmed);
663        }
664        if resp.truncated {
665            return Ok(format!("{}\n... [truncated by gitserver]\n", resp.diff));
666        }
667        Ok(resp.diff)
668    }
669
670    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
671        let resp: RemotesResp = self.post_json("remotes", &EmptyReq).await?;
672        Ok(resp
673            .remotes
674            .into_iter()
675            .map(|r| WorkspaceGitRemote {
676                name: r.name,
677                url: r.url,
678                direction: r.direction,
679            })
680            .collect())
681    }
682}
683
684#[async_trait]
685impl WorkspaceGitStashProvider for RemoteGitBackend {
686    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
687        let resp: StashesResp = self.post_json("stashes", &EmptyReq).await?;
688        Ok(resp
689            .stashes
690            .into_iter()
691            .map(|s| WorkspaceGitStash {
692                index: s.index,
693                message: s.message,
694            })
695            .collect())
696    }
697
698    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
699        self.post_unit(
700            "stashes/create",
701            &StashCreateReq {
702                message: request.message,
703                include_untracked: request.include_untracked,
704            },
705        )
706        .await
707    }
708}
709
710/// Read the mTLS cert + key PEM files and assemble a `reqwest::Identity`.
711///
712/// `reqwest::Identity::from_pem` (with the `rustls-tls` backend) wants a
713/// single PEM blob containing the certificate chain followed by the
714/// private key. We concatenate the two files with a newline separator —
715/// stray trailing newlines in either file are tolerated by the PEM
716/// parser. Errors at every step (file I/O, PEM parsing) are mapped to
717/// `anyhow` with the source path included so misconfigurations surface
718/// clearly.
719fn load_mtls_identity(
720    cert_path: &std::path::Path,
721    key_path: &std::path::Path,
722) -> Result<reqwest::Identity> {
723    let cert =
724        crate::bounded_io::read_file_bounded(cert_path, crate::bounded_io::MAX_CONFIG_FILE_BYTES)
725            .map_err(|e| {
726            anyhow!(
727                "failed to read mTLS client_cert_pem at {}: {}",
728                cert_path.display(),
729                e
730            )
731        })?;
732    let key =
733        crate::bounded_io::read_file_bounded(key_path, crate::bounded_io::MAX_CONFIG_FILE_BYTES)
734            .map_err(|e| {
735                anyhow!(
736                    "failed to read mTLS client_key_pem at {}: {}",
737                    key_path.display(),
738                    e
739                )
740            })?;
741
742    let mut pem = Vec::with_capacity(cert.len() + key.len() + 1);
743    pem.extend_from_slice(&cert);
744    if !cert.ends_with(b"\n") {
745        pem.push(b'\n');
746    }
747    pem.extend_from_slice(&key);
748
749    reqwest::Identity::from_pem(&pem).map_err(|e| {
750        anyhow!(
751            "failed to parse mTLS PEM material (cert={}, key={}): {}",
752            cert_path.display(),
753            key_path.display(),
754            e
755        )
756    })
757}
758
759/// Truncate `s` to at most `max_bytes`, rounding down to the nearest UTF-8
760/// character boundary to keep the result a valid `&str`.
761fn safe_utf8_truncate(s: &str, max_bytes: usize) -> usize {
762    if s.len() <= max_bytes {
763        return s.len();
764    }
765    let mut idx = max_bytes;
766    while idx > 0 && !s.is_char_boundary(idx) {
767        idx -= 1;
768    }
769    idx
770}
771
772/// Map a non-2xx response to an `anyhow::Error`, attaching a typed
773/// [`RemoteGitConflict`] when the server returned a recoverable code under
774/// 409 or 422.
775///
776/// Synchronous and takes the pre-fetched response body so it can be shared
777/// between callers that hold a `reqwest::Response` and callers that have
778/// already streamed the body into a `Vec<u8>` (for size-capped paths).
779fn map_error_response(op: &'static str, status: StatusCode, body: &str) -> anyhow::Error {
780    let parsed: Option<RemoteErrorBody> = serde_json::from_str(body).ok();
781
782    let (code, message) = match parsed {
783        Some(b) => (b.error.code, b.error.message),
784        None => (format!("HTTP_{}", status.as_u16()), body.to_string()),
785    };
786
787    let status_u16 = status.as_u16();
788    if status_u16 == 409 || status_u16 == 422 {
789        return anyhow::Error::new(RemoteGitConflict { code, message });
790    }
791
792    match status_u16 {
793        400 => anyhow!("remote git '{}' bad request: {}: {}", op, code, message),
794        401 | 403 => anyhow!("remote git '{}' auth failed: {}: {}", op, code, message),
795        404 => anyhow!("remote git '{}' not found: {}: {}", op, code, message),
796        500..=599 => anyhow!(
797            "remote git '{}' server error ({}): {}: {}",
798            op,
799            status_u16,
800            code,
801            message
802        ),
803        _ => anyhow!(
804            "remote git '{}' unexpected status {}: {}: {}",
805            op,
806            status_u16,
807            code,
808            message
809        ),
810    }
811}
812
813#[derive(Deserialize)]
814struct RemoteErrorBody {
815    error: RemoteErrorDetail,
816}
817
818#[derive(Deserialize)]
819struct RemoteErrorDetail {
820    code: String,
821    #[serde(default)]
822    message: String,
823}
824
825/// Emit a structured `tracing::debug!` event for a single gitserver call.
826///
827/// Mirrors the metering shape used by `S3WorkspaceBackend::emit_s3_call_event`
828/// so a single subscriber can meter both backends. Fields:
829///
830/// | Field         | Meaning                                          |
831/// |---------------|--------------------------------------------------|
832/// | `op`          | gitserver op (`status`, `log`, `diff`, ...)      |
833/// | `repo_id`     | opaque repo identifier                           |
834/// | `status`      | HTTP status code (when the request reached server) |
835/// | `outcome`     | `ok` \| `error`                                   |
836/// | `bytes`       | response body length, when known                  |
837/// | `duration_ms` | wall-clock                                       |
838fn emit_remote_git_event(
839    op: &'static str,
840    repo_id: &str,
841    status: Option<u16>,
842    ok: bool,
843    elapsed: Duration,
844    bytes: Option<u64>,
845) {
846    tracing::debug!(
847        op = format!("git.{}", op),
848        repo_id = %repo_id,
849        status = status.unwrap_or(0),
850        outcome = if ok { "ok" } else { "error" },
851        bytes = bytes.unwrap_or(0),
852        duration_ms = elapsed.as_millis() as u64,
853    );
854}
855
856impl super::WorkspaceServices {
857    /// Attach a remote git provider to an existing [`super::WorkspaceServices`].
858    ///
859    /// Returns a new `Arc<WorkspaceServices>` with `git` and `git_stash`
860    /// wired to the remote backend. The original `WorkspaceServices` is
861    /// not mutated. `git_worktree` is intentionally reset to `None` —
862    /// worktrees are a local-filesystem concept that does not map cleanly
863    /// onto a remote service (see RFC §8). All other fields — including
864    /// `local_root`, the command runner, the search provider, the
865    /// optional `file_system_ext` (S3 CAS), and `operation_timeout` — are
866    /// preserved verbatim via the internal `with_git_provider` constructor.
867    pub fn with_remote_git(self: Arc<Self>, config: RemoteGitBackendConfig) -> Result<Arc<Self>> {
868        let backend = RemoteGitBackend::new(config)?;
869        let git: Arc<dyn WorkspaceGit> = backend.clone();
870        let stash: Arc<dyn WorkspaceGitStashProvider> = backend;
871        Ok(self.with_git_provider(git, Some(stash)))
872    }
873}
874
875#[cfg(test)]
876#[path = "remote_git/tests.rs"]
877mod tests;