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