Skip to main content

strop_git/
exec.rs

1//! Three real Git execution backends (0036 RW7/RW8, 0037 DC1b): local
2//! `git` via std::process with `-C workdir`, bounded remote `git` via
3//! the shared remote-execution boundary ([`strop_remote::run`]), and
4//! bounded in-container `git` via `docker exec` argv through
5//! [`strop_containers::exec_capture`]. Not a provider framework — the
6//! enum's variants are every backend that exists.
7//!
8//! Exit codes are data on both sides (`diff --quiet` exits 1): [`GitRun`]
9//! reports success and the raw code; callers that need "did git itself
10//! refuse" match on the code, not on stderr text. Remote output bounds
11//! are surfaced as `dropped` counts — a parser that needs a complete
12//! record stream must refuse truncated output, never parse a prefix as
13//! if it were the whole answer.
14
15use std::ffi::OsString;
16use std::path::Path;
17
18use strop_containers::ContainerError;
19use strop_core::worker::CancelToken;
20use strop_remote::{RemoteCommand, RemoteCommandError};
21use strop_workspace::{ContainerId, RemoteEndpoint};
22
23use crate::target::RepoTarget;
24
25/// One bounded `git` run's native bytes.
26#[derive(Debug, Clone)]
27pub struct GitRun {
28    /// `true` when the process exited zero.
29    pub success: bool,
30    /// The raw exit code where one exists (signals carry `None`).
31    pub code: Option<i32>,
32    pub stdout: Vec<u8>,
33    pub stderr: Vec<u8>,
34    /// Bytes the remote supervisor dropped past its output bound. A
35    /// nonzero count means `stdout`/`stderr` are heads (plus a stderr
36    /// tail), not the complete stream.
37    pub stdout_dropped: u64,
38    pub stderr_dropped: u64,
39}
40
41impl GitRun {
42    /// Fail typed when the caller needs the complete stdout record
43    /// stream: a truncated head must never parse as "everything git
44    /// said" (0036: no swallowed output errors).
45    pub fn require_full_stdout(&self, op: &str) -> Result<&[u8], String> {
46        if self.stdout_dropped > 0 {
47            return Err(format!(
48                "{op}: remote output truncated ({} bytes dropped)",
49                self.stdout_dropped
50            ));
51        }
52        Ok(&self.stdout)
53    }
54}
55
56/// Why a bounded `git` run could not produce its bytes.
57#[derive(Debug)]
58pub enum GitExecError {
59    /// The local `git` process could not be started.
60    Spawn(String),
61    /// The remote execution boundary refused or failed; the display
62    /// keeps the boundary's own typed diagnosis (transport, supervisor,
63    /// missing tooling, cancellation, timeout…).
64    Remote(RemoteCommandError),
65    /// The container engine boundary refused or failed (engine
66    /// unavailable, container not running, output bound exceeded,
67    /// cancellation…), or the workdir/argv could not be carried as
68    /// UTF-8 `docker exec` argv — a typed refusal, never a lossy guess.
69    Container(ContainerError),
70}
71
72impl std::fmt::Display for GitExecError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            Self::Spawn(message) => write!(f, "{message}"),
76            Self::Remote(error) => write!(f, "{error}"),
77            Self::Container(error) => write!(f, "{error}"),
78        }
79    }
80}
81
82/// Retained stdout for one in-container `git` run — the same scale the
83/// remote backend's supervisor keeps (16 MiB); a parser that needs the
84/// complete stream sees the dropped count and must refuse a head.
85const CONTAINER_STDOUT_LIMIT: u64 = 16 * 1024 * 1024;
86
87/// One [`OsString`] as `docker exec` argv text. The container boundary
88/// carries argv as `String`s, so a non-UTF-8 argument (a native-byte
89/// filename) is refused typed rather than lossily renamed — the remote
90/// backend can carry those bytes; this one cannot.
91fn argv_text(arg: &OsString) -> Result<String, GitExecError> {
92    arg.clone().into_string().map_err(|arg| {
93        GitExecError::Container(ContainerError::CapabilityRefused {
94            what: format!(
95                "container git: argument is not UTF-8 ({:?})",
96                arg.to_string_lossy()
97            ),
98        })
99    })
100}
101
102/// Where one Git query executes. Constructed from a [`RepoTarget`] so a
103/// non-local workdir can only ever reach its matching backend.
104#[derive(Debug, Clone)]
105pub enum GitExec<'a> {
106    Local {
107        workdir: &'a Path,
108    },
109    Remote {
110        endpoint: RemoteEndpoint,
111        workdir: &'a Path,
112    },
113    Container {
114        container: ContainerId,
115        workdir: &'a Path,
116    },
117}
118
119impl<'a> GitExec<'a> {
120    /// The backend for a repository target: the local worktree runs
121    /// `git -C <workdir>` in-process; the remote worktree rides one
122    /// bounded supervised connection per run; the container worktree
123    /// rides one bounded `docker exec` per run. No client state is held
124    /// between runs.
125    pub fn for_target(target: &'a RepoTarget) -> Self {
126        match target {
127            RepoTarget::Local { workdir } => Self::Local { workdir },
128            RepoTarget::Remote { endpoint, workdir } => Self::Remote {
129                endpoint: endpoint.clone(),
130                workdir,
131            },
132            RepoTarget::Container { container, workdir } => Self::Container {
133                container: container.clone(),
134                workdir,
135            },
136        }
137    }
138
139    /// Run one bounded `git <argv>` in the repository. `argv` excludes
140    /// the workdir placement — the local and container variants prefix
141    /// `-C`, the remote variant sets the supervised cwd — so every argv
142    /// element stays one inert argument on every backend, including
143    /// filenames with spaces or newlines. (The container boundary
144    /// carries argv as UTF-8 text, so a native-byte filename is refused
145    /// typed there rather than lossily renamed.)
146    pub fn run(&self, argv: &[OsString], cancel: &CancelToken) -> Result<GitRun, GitExecError> {
147        match self {
148            Self::Local { workdir } => {
149                let output = std::process::Command::new("git")
150                    .arg("-C")
151                    .arg(workdir)
152                    .args(argv)
153                    .output()
154                    .map_err(|error| {
155                        GitExecError::Spawn(format!(
156                            "spawn git {}: {error}",
157                            argv.first()
158                                .map(|a| a.to_string_lossy().into_owned())
159                                .unwrap_or_default()
160                        ))
161                    })?;
162                Ok(GitRun {
163                    success: output.status.success(),
164                    code: output.status.code(),
165                    stdout: output.stdout,
166                    stderr: output.stderr,
167                    stdout_dropped: 0,
168                    stderr_dropped: 0,
169                })
170            }
171            Self::Remote { endpoint, workdir } => {
172                let command = RemoteCommand::new("git", argv.to_vec(), workdir)
173                    .map_err(GitExecError::Remote)?;
174                let output =
175                    strop_remote::run(endpoint, &command, cancel).map_err(GitExecError::Remote)?;
176                Ok(GitRun {
177                    success: output.status.success(),
178                    code: output
179                        .status
180                        .code()
181                        .and_then(|code| i32::try_from(code).ok()),
182                    stdout: output.stdout,
183                    stderr: output.stderr,
184                    stdout_dropped: output.stdout_dropped,
185                    stderr_dropped: output.stderr_dropped,
186                })
187            }
188            Self::Container { container, workdir } => {
189                // `git -C <workdir>` keeps the argv shape identical to
190                // the local backend; `--workdir` additionally places
191                // the exec there. Both need the workdir as UTF-8 text —
192                // a native-byte path is refused, never guessed lossy.
193                let Some(workdir_text) = workdir.to_str() else {
194                    return Err(GitExecError::Container(ContainerError::CapabilityRefused {
195                        what: "container git: the working directory is not UTF-8".into(),
196                    }));
197                };
198                let mut args = Vec::with_capacity(argv.len() + 2);
199                args.push("-C".to_string());
200                args.push(workdir_text.to_string());
201                for arg in argv {
202                    args.push(argv_text(arg)?);
203                }
204                let engine = strop_containers::engine(cancel).map_err(GitExecError::Container)?;
205                let output = strop_containers::exec_capture(
206                    &engine,
207                    container,
208                    "git",
209                    &args,
210                    workdir,
211                    CONTAINER_STDOUT_LIMIT,
212                    cancel,
213                )
214                .map_err(GitExecError::Container)?;
215                Ok(GitRun {
216                    success: output.code == Some(0),
217                    code: output.code,
218                    stdout: output.stdout,
219                    stderr: output.stderr,
220                    stdout_dropped: output.stdout_dropped,
221                    // The container capture surface retains a bounded
222                    // stderr without reporting a dropped count.
223                    stderr_dropped: 0,
224                })
225            }
226        }
227    }
228
229    /// Run one bounded `git` invocation that must exit zero with a
230    /// complete stdout record stream, returning those bytes. Nonzero
231    /// exits and truncated output are typed errors carrying git's own
232    /// stderr — the shared shape every memory query wants.
233    pub fn run_records(
234        &self,
235        op: &str,
236        argv: &[OsString],
237        cancel: &CancelToken,
238    ) -> Result<Vec<u8>, String> {
239        let run = self
240            .run(argv, cancel)
241            .map_err(|error| format!("{op}: {error}"))?;
242        if !run.success {
243            return Err(format!(
244                "{op}: {}",
245                String::from_utf8_lossy(&run.stderr).trim()
246            ));
247        }
248        if run.stderr_dropped > 0 {
249            return Err(format!(
250                "{op}: remote stderr truncated ({} bytes dropped)",
251                run.stderr_dropped
252            ));
253        }
254        run.require_full_stdout(op).map(|bytes| bytes.to_vec())
255    }
256}
257
258/// Test seam: hand one closure a real worker-issued cancellation
259/// token (CancelToken cannot be constructed outside strop-core's
260/// worker machinery) and return its value.
261#[cfg(test)]
262pub(crate) fn with_token<T>(work: impl FnOnce(CancelToken) -> T) -> T {
263    let (tokens, receiver) = std::sync::mpsc::channel();
264    let (release, waiting) = std::sync::mpsc::channel::<()>();
265    let owner = strop_core::worker::spawn(
266        "git-exec-test",
267        |_| {},
268        move |token| {
269            tokens.send(token).expect("test receives token");
270            let _ = waiting.recv();
271            strop_core::worker::Outcome::Success(())
272        },
273    );
274    let token = receiver.recv().expect("worker issued token");
275    let result = work(token);
276    drop(release);
277    drop(owner);
278    result
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[cfg(unix)]
286    #[test]
287    fn native_path_arguments_select_the_exact_index_entry() {
288        use std::os::unix::ffi::OsStrExt;
289        let directory = tempfile::tempdir().unwrap();
290        let repo = git2::Repository::init(directory.path()).unwrap();
291        let name = std::ffi::OsStr::from_bytes(b"- odd \xff.txt");
292        let path = std::path::Path::new(name);
293        std::fs::write(directory.path().join(path), "content\n").unwrap();
294        let mut index = repo.index().unwrap();
295        index.add_path(path).unwrap();
296        index.write().unwrap();
297        let exec = GitExec::Local {
298            workdir: directory.path(),
299        };
300        let argv = ["ls-files".into(), "-z".into(), "--".into(), name.into()];
301        let run = with_token(|token| exec.run(&argv, &token)).expect("git runs");
302        assert!(run.success);
303        assert_eq!(run.stdout, b"- odd \xff.txt\0");
304    }
305
306    /// Nonzero exits are data, not errors: the run succeeds as a run,
307    /// and the caller reads the code.
308    #[test]
309    fn local_exit_codes_are_data() {
310        let directory = tempfile::tempdir().unwrap();
311        let _repo = git2::Repository::init(directory.path()).unwrap();
312        let exec = GitExec::Local {
313            workdir: directory.path(),
314        };
315        let argv: Vec<OsString> = vec![
316            "rev-parse".into(),
317            "--verify".into(),
318            "--quiet".into(),
319            "no-such-ref".into(),
320        ];
321        let run = with_token(|token| exec.run(&argv, &token)).expect("git runs");
322        assert!(!run.success);
323        assert_eq!(run.code, Some(1));
324    }
325
326    /// run_records refuses a nonzero exit with git's own stderr — an
327    /// honest message, never a silent empty record set.
328    #[test]
329    fn run_records_reports_nonzero_exits() {
330        let directory = tempfile::tempdir().unwrap();
331        let _repo = git2::Repository::init(directory.path()).unwrap();
332        let exec = GitExec::Local {
333            workdir: directory.path(),
334        };
335        let argv: Vec<OsString> = vec!["log".into(), "--format=".into(), "no-such-sha".into()];
336        assert!(
337            with_token(|token| exec.run_records("git log", &argv, &token)).is_err(),
338            "an invalid revision must not become an empty successful record set"
339        );
340    }
341
342    /// require_full_stdout refuses a head posing as the whole stream.
343    #[test]
344    fn truncated_stdout_is_refused() {
345        let run = GitRun {
346            success: true,
347            code: Some(0),
348            stdout: b"only-a-head".to_vec(),
349            stderr: Vec::new(),
350            stdout_dropped: 4096,
351            stderr_dropped: 0,
352        };
353        let error = run.require_full_stdout("git log").unwrap_err();
354        assert!(error.contains("truncated"), "{error}");
355    }
356
357    /// The backend is chosen by the target: a remote RepoTarget can
358    /// only construct the remote exec, a local one only local.
359    #[test]
360    fn for_target_selects_the_only_valid_backend() {
361        let local = RepoTarget::Local {
362            workdir: std::path::PathBuf::from("/w"),
363        };
364        assert!(matches!(GitExec::for_target(&local), GitExec::Local { .. }));
365        let remote = RepoTarget::Remote {
366            endpoint: RemoteEndpoint::parse("ssh://fixture@box:2222").unwrap(),
367            workdir: std::path::PathBuf::from("/srv/proj"),
368        };
369        match GitExec::for_target(&remote) {
370            GitExec::Remote { endpoint, workdir } => {
371                assert_eq!(endpoint, remote.endpoint().unwrap().clone());
372                assert_eq!(workdir, Path::new("/srv/proj"));
373            }
374            other => panic!("remote target built {other:?}"),
375        }
376    }
377
378    /// A container RepoTarget constructs the container exec carrying
379    /// the canonical id and workdir — nothing else can.
380    #[test]
381    fn for_target_selects_container_for_a_container_target() {
382        let target = RepoTarget::Container {
383            container: ContainerId::canonical("d".repeat(64)).unwrap(),
384            workdir: std::path::PathBuf::from("/work/src"),
385        };
386        match GitExec::for_target(&target) {
387            GitExec::Container { container, workdir } => {
388                assert_eq!(container.as_str(), &"d".repeat(64));
389                assert_eq!(workdir, Path::new("/work/src"));
390            }
391            other => panic!("container target built {other:?}"),
392        }
393    }
394
395    /// A container run's argv starts `-C <workdir>` and refuses a
396    /// non-UTF-8 workdir before the engine is ever probed — a typed
397    /// refusal, never a lossy path guess. (A valid workdir would reach
398    /// the engine probe next, which has no fixture here.)
399    #[cfg(unix)]
400    #[test]
401    fn container_run_refuses_a_non_utf8_workdir_typed() {
402        use std::os::unix::ffi::OsStrExt;
403        let workdir = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b"/w/\xff"));
404        let exec = GitExec::Container {
405            container: ContainerId::canonical("d".repeat(64)).unwrap(),
406            workdir: &workdir,
407        };
408        let argv: Vec<OsString> = vec!["status".into()];
409        let error = with_token(|token| exec.run(&argv, &token)).unwrap_err();
410        match error {
411            GitExecError::Container(ContainerError::CapabilityRefused { what }) => {
412                assert!(what.contains("UTF-8"), "{what}");
413            }
414            other => panic!("expected a typed capability refusal, got {other:?}"),
415        }
416    }
417
418    /// A native-byte argv element (a filename git itself would accept
419    /// locally) is refused typed at the container boundary — the remote
420    /// backend carries such bytes; `docker exec` argv text cannot.
421    #[cfg(unix)]
422    #[test]
423    fn container_run_refuses_a_non_utf8_argument_typed() {
424        use std::os::unix::ffi::OsStrExt;
425        let workdir = std::path::PathBuf::from("/work");
426        let exec = GitExec::Container {
427            container: ContainerId::canonical("d".repeat(64)).unwrap(),
428            workdir: &workdir,
429        };
430        let argv: Vec<OsString> = vec![
431            "ls-files".into(),
432            std::ffi::OsStr::from_bytes(b"\xff.txt").into(),
433        ];
434        let error = with_token(|token| exec.run(&argv, &token)).unwrap_err();
435        assert!(
436            matches!(
437                error,
438                GitExecError::Container(ContainerError::CapabilityRefused { .. })
439            ),
440            "{error:?}"
441        );
442    }
443
444    /// The container failure surfaces through GitExecError's display
445    /// with the boundary's own typed diagnosis.
446    #[test]
447    fn container_error_displays_the_boundary_diagnosis() {
448        let error = GitExecError::Container(ContainerError::NotRunning { id: "abc".into() });
449        assert_eq!(error.to_string(), "container is not running: abc");
450    }
451}