1use 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#[derive(Debug, Clone)]
25pub struct GitRun {
26 pub success: bool,
28 pub code: Option<i32>,
30 pub stdout: Vec<u8>,
31 pub stderr: Vec<u8>,
32 pub stdout_dropped: u64,
36 pub stderr_dropped: u64,
37}
38
39impl GitRun {
40 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#[derive(Debug)]
56pub enum GitExecError {
57 Spawn(String),
59 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#[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 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 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 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#[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 #[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 #[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 #[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 #[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}