Skip to main content

strop_git/
exec.rs

1//! Two real Git execution backends (0036 RW7/RW8): local `git` via
2//! std::process with `-C workdir`, and bounded remote `git` via the
3//! shared remote-execution boundary ([`strop_remote::run`]). Not a
4//! provider framework — the enum's two variants are every backend that
5//! exists, and the remote variant is the only path that can carry a
6//! remote workdir anywhere.
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_core::worker::CancelToken;
19use strop_remote::{RemoteCommand, RemoteCommandError, RemoteEndpoint};
20
21use crate::target::RepoTarget;
22
23/// One bounded `git` run's native bytes.
24#[derive(Debug, Clone)]
25pub struct GitRun {
26    /// `true` when the process exited zero.
27    pub success: bool,
28    /// The raw exit code where one exists (signals carry `None`).
29    pub code: Option<i32>,
30    pub stdout: Vec<u8>,
31    pub stderr: Vec<u8>,
32    /// Bytes the remote supervisor dropped past its output bound. A
33    /// nonzero count means `stdout`/`stderr` are heads (plus a stderr
34    /// tail), not the complete stream.
35    pub stdout_dropped: u64,
36    pub stderr_dropped: u64,
37}
38
39impl GitRun {
40    /// Fail typed when the caller needs the complete stdout record
41    /// stream: a truncated head must never parse as "everything git
42    /// said" (0036: no swallowed output errors).
43    pub fn require_full_stdout(&self, op: &str) -> Result<&[u8], String> {
44        if self.stdout_dropped > 0 {
45            return Err(format!(
46                "{op}: remote output truncated ({} bytes dropped)",
47                self.stdout_dropped
48            ));
49        }
50        Ok(&self.stdout)
51    }
52}
53
54/// Why a bounded `git` run could not produce its bytes.
55#[derive(Debug)]
56pub enum GitExecError {
57    /// The local `git` process could not be started.
58    Spawn(String),
59    /// The remote execution boundary refused or failed; the display
60    /// keeps the boundary's own typed diagnosis (transport, supervisor,
61    /// missing tooling, cancellation, timeout…).
62    Remote(RemoteCommandError),
63}
64
65impl std::fmt::Display for GitExecError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::Spawn(message) => write!(f, "{message}"),
69            Self::Remote(error) => write!(f, "{error}"),
70        }
71    }
72}
73
74/// Where one Git query executes. Constructed from a [`RepoTarget`] so a
75/// remote workdir can only ever reach the remote variant.
76#[derive(Debug, Clone)]
77pub enum GitExec<'a> {
78    Local {
79        workdir: &'a Path,
80    },
81    Remote {
82        endpoint: RemoteEndpoint,
83        workdir: &'a Path,
84    },
85}
86
87impl<'a> GitExec<'a> {
88    /// The backend for a repository target: the local worktree runs
89    /// `git -C <workdir>` in-process; the remote worktree rides one
90    /// bounded supervised connection per run. No client state is held
91    /// between runs.
92    pub fn for_target(target: &'a RepoTarget) -> Self {
93        match target {
94            RepoTarget::Local { workdir } => Self::Local { workdir },
95            RepoTarget::Remote { endpoint, workdir } => Self::Remote {
96                endpoint: endpoint.clone(),
97                workdir,
98            },
99        }
100    }
101
102    /// Run one bounded `git <argv>` in the repository. `argv` excludes
103    /// the workdir placement — the local variant prefixes `-C`, the
104    /// remote variant sets the supervised cwd — so every argv element
105    /// stays one inert argument on both sides, including filenames with
106    /// spaces, newlines or non-UTF-8 bytes.
107    pub fn run(&self, argv: &[OsString], cancel: &CancelToken) -> Result<GitRun, GitExecError> {
108        match self {
109            Self::Local { workdir } => {
110                let output = std::process::Command::new("git")
111                    .arg("-C")
112                    .arg(workdir)
113                    .args(argv)
114                    .output()
115                    .map_err(|error| {
116                        GitExecError::Spawn(format!(
117                            "spawn git {}: {error}",
118                            argv.first()
119                                .map(|a| a.to_string_lossy().into_owned())
120                                .unwrap_or_default()
121                        ))
122                    })?;
123                Ok(GitRun {
124                    success: output.status.success(),
125                    code: output.status.code(),
126                    stdout: output.stdout,
127                    stderr: output.stderr,
128                    stdout_dropped: 0,
129                    stderr_dropped: 0,
130                })
131            }
132            Self::Remote { endpoint, workdir } => {
133                let command = RemoteCommand::new("git", argv.to_vec(), workdir)
134                    .map_err(GitExecError::Remote)?;
135                let output =
136                    strop_remote::run(endpoint, &command, cancel).map_err(GitExecError::Remote)?;
137                Ok(GitRun {
138                    success: output.status.success(),
139                    code: output
140                        .status
141                        .code()
142                        .and_then(|code| i32::try_from(code).ok()),
143                    stdout: output.stdout,
144                    stderr: output.stderr,
145                    stdout_dropped: output.stdout_dropped,
146                    stderr_dropped: output.stderr_dropped,
147                })
148            }
149        }
150    }
151
152    /// Run one bounded `git` invocation that must exit zero with a
153    /// complete stdout record stream, returning those bytes. Nonzero
154    /// exits and truncated output are typed errors carrying git's own
155    /// stderr — the shared shape every memory query wants.
156    pub fn run_records(
157        &self,
158        op: &str,
159        argv: &[OsString],
160        cancel: &CancelToken,
161    ) -> Result<Vec<u8>, String> {
162        let run = self
163            .run(argv, cancel)
164            .map_err(|error| format!("{op}: {error}"))?;
165        if !run.success {
166            return Err(format!(
167                "{op}: {}",
168                String::from_utf8_lossy(&run.stderr).trim()
169            ));
170        }
171        if run.stderr_dropped > 0 {
172            return Err(format!(
173                "{op}: remote stderr truncated ({} bytes dropped)",
174                run.stderr_dropped
175            ));
176        }
177        run.require_full_stdout(op).map(|bytes| bytes.to_vec())
178    }
179}
180
181/// Test seam: hand one closure a real worker-issued cancellation
182/// token (CancelToken cannot be constructed outside strop-core's
183/// worker machinery) and return its value.
184#[cfg(test)]
185pub(crate) fn with_token<T>(work: impl FnOnce(CancelToken) -> T) -> T {
186    let (tokens, receiver) = std::sync::mpsc::channel();
187    let (release, waiting) = std::sync::mpsc::channel::<()>();
188    let owner = strop_core::worker::spawn(
189        "git-exec-test",
190        |_| {},
191        move |token| {
192            tokens.send(token).expect("test receives token");
193            let _ = waiting.recv();
194            strop_core::worker::Outcome::Success(())
195        },
196    );
197    let token = receiver.recv().expect("worker issued token");
198    let result = work(token);
199    drop(release);
200    drop(owner);
201    result
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[cfg(unix)]
209    #[test]
210    fn native_path_arguments_select_the_exact_index_entry() {
211        use std::os::unix::ffi::OsStrExt;
212        let directory = tempfile::tempdir().unwrap();
213        let repo = git2::Repository::init(directory.path()).unwrap();
214        let name = std::ffi::OsStr::from_bytes(b"- odd \xff.txt");
215        let path = std::path::Path::new(name);
216        std::fs::write(directory.path().join(path), "content\n").unwrap();
217        let mut index = repo.index().unwrap();
218        index.add_path(path).unwrap();
219        index.write().unwrap();
220        let exec = GitExec::Local {
221            workdir: directory.path(),
222        };
223        let argv = ["ls-files".into(), "-z".into(), "--".into(), name.into()];
224        let run = with_token(|token| exec.run(&argv, &token)).expect("git runs");
225        assert!(run.success);
226        assert_eq!(run.stdout, b"- odd \xff.txt\0");
227    }
228
229    /// Nonzero exits are data, not errors: the run succeeds as a run,
230    /// and the caller reads the code.
231    #[test]
232    fn local_exit_codes_are_data() {
233        let directory = tempfile::tempdir().unwrap();
234        let _repo = git2::Repository::init(directory.path()).unwrap();
235        let exec = GitExec::Local {
236            workdir: directory.path(),
237        };
238        let argv: Vec<OsString> = vec![
239            "rev-parse".into(),
240            "--verify".into(),
241            "--quiet".into(),
242            "no-such-ref".into(),
243        ];
244        let run = with_token(|token| exec.run(&argv, &token)).expect("git runs");
245        assert!(!run.success);
246        assert_eq!(run.code, Some(1));
247    }
248
249    /// run_records refuses a nonzero exit with git's own stderr — an
250    /// honest message, never a silent empty record set.
251    #[test]
252    fn run_records_reports_nonzero_exits() {
253        let directory = tempfile::tempdir().unwrap();
254        let _repo = git2::Repository::init(directory.path()).unwrap();
255        let exec = GitExec::Local {
256            workdir: directory.path(),
257        };
258        let argv: Vec<OsString> = vec!["log".into(), "--format=".into(), "no-such-sha".into()];
259        assert!(
260            with_token(|token| exec.run_records("git log", &argv, &token)).is_err(),
261            "an invalid revision must not become an empty successful record set"
262        );
263    }
264
265    /// require_full_stdout refuses a head posing as the whole stream.
266    #[test]
267    fn truncated_stdout_is_refused() {
268        let run = GitRun {
269            success: true,
270            code: Some(0),
271            stdout: b"only-a-head".to_vec(),
272            stderr: Vec::new(),
273            stdout_dropped: 4096,
274            stderr_dropped: 0,
275        };
276        let error = run.require_full_stdout("git log").unwrap_err();
277        assert!(error.contains("truncated"), "{error}");
278    }
279
280    /// The backend is chosen by the target: a remote RepoTarget can
281    /// only construct the remote exec, a local one only local.
282    #[test]
283    fn for_target_selects_the_only_valid_backend() {
284        let local = RepoTarget::Local {
285            workdir: std::path::PathBuf::from("/w"),
286        };
287        assert!(matches!(GitExec::for_target(&local), GitExec::Local { .. }));
288        let remote = RepoTarget::Remote {
289            endpoint: RemoteEndpoint::parse("ssh://fixture@box:2222").unwrap(),
290            workdir: std::path::PathBuf::from("/srv/proj"),
291        };
292        match GitExec::for_target(&remote) {
293            GitExec::Remote { endpoint, workdir } => {
294                assert_eq!(endpoint, remote.endpoint().unwrap().clone());
295                assert_eq!(workdir, Path::new("/srv/proj"));
296            }
297            other => panic!("remote target built {other:?}"),
298        }
299    }
300}