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