1use std::ffi::OsString;
22use std::path::{Path, PathBuf};
23
24use strop_core::worker::CancelToken;
25use strop_workspace::RemoteEndpoint;
26
27use crate::diff::FileDiff;
28use crate::exec::{GitExec, GitExecError, GitRun};
29use crate::repo::{gutter_from_contents, hunks_from_buffers};
30use crate::ssh::parse_effective_hostname;
31use crate::target::RepoTarget;
32use crate::{GitContext, Hunk};
33
34#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum RemoteGitError {
39 Exec(String),
43 Exit {
46 op: &'static str,
47 code: i32,
48 stderr: String,
49 },
50 Parse(&'static str),
53 Utf8(&'static str),
55 Truncated { op: &'static str, dropped: u64 },
58}
59
60impl std::fmt::Display for RemoteGitError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::Exec(message) => write!(f, "{message}"),
64 Self::Exit { op, code, stderr } => {
65 write!(f, "{op}: git exited {code}: {}", stderr.trim())
66 }
67 Self::Parse(what) => write!(f, "{what}: unparseable git output"),
68 Self::Utf8(what) => write!(f, "{what} is not UTF-8"),
69 Self::Truncated { op, dropped } => {
70 write!(f, "{op}: remote output truncated ({dropped} bytes dropped)")
71 }
72 }
73 }
74}
75
76impl std::error::Error for RemoteGitError {}
77
78impl From<GitExecError> for RemoteGitError {
79 fn from(error: GitExecError) -> Self {
80 Self::Exec(error.to_string())
81 }
82}
83
84fn records(
87 exec: &GitExec,
88 op: &'static str,
89 argv: &[OsString],
90 cancel: &CancelToken,
91) -> Result<Vec<u8>, RemoteGitError> {
92 let run = exec.run(argv, cancel)?;
93 exit_or_bytes(op, &run)
94}
95
96fn exit_or_bytes(op: &'static str, run: &GitRun) -> Result<Vec<u8>, RemoteGitError> {
97 if run.stdout_dropped > 0 {
98 return Err(RemoteGitError::Truncated {
99 op,
100 dropped: run.stdout_dropped,
101 });
102 }
103 match run.code {
104 Some(0) => Ok(run.stdout.clone()),
105 Some(code) => Err(RemoteGitError::Exit {
106 op,
107 code,
108 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
109 }),
110 None => Err(RemoteGitError::Exit {
111 op,
112 code: -1,
113 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
114 }),
115 }
116}
117
118fn remotes_from_run(config_run: &GitRun) -> Result<Vec<(String, String)>, RemoteGitError> {
119 const OP: &str = "config --get-regexp remote.*.url";
120 if config_run.code != Some(0) && config_run.code != Some(1) {
121 return Err(RemoteGitError::Exit {
122 op: OP,
123 code: config_run.code.unwrap_or(-1),
124 stderr: String::from_utf8_lossy(&config_run.stderr)
125 .trim_end()
126 .to_string(),
127 });
128 }
129 if config_run.stdout_dropped > 0 {
130 return Err(RemoteGitError::Truncated {
131 op: OP,
132 dropped: config_run.stdout_dropped,
133 });
134 }
135 parse_remote_config(&config_run.stdout)
136}
137
138pub fn discover(
144 endpoint: &RemoteEndpoint,
145 from: &Path,
146 cancel: &CancelToken,
147) -> Result<Option<PathBuf>, RemoteGitError> {
148 let exec = GitExec::Remote {
149 endpoint: endpoint.clone(),
150 workdir: from,
151 };
152 discover_with(&exec, cancel)
153}
154
155pub(crate) fn discover_with(
159 exec: &GitExec,
160 cancel: &CancelToken,
161) -> Result<Option<PathBuf>, RemoteGitError> {
162 let run = exec.run(&["rev-parse".into(), "--show-toplevel".into()], cancel)?;
163 discover_from_run(&run)
164}
165
166pub(crate) fn discover_from_run(run: &GitRun) -> Result<Option<PathBuf>, RemoteGitError> {
171 if run.success {
172 let stdout = exit_or_bytes("rev-parse --show-toplevel", run)?;
173 return parse_toplevel(&stdout).map(Some);
174 }
175 let stderr = String::from_utf8_lossy(&run.stderr);
176 if run.code == Some(128) && stderr.contains("not a git repository") {
177 return Ok(None);
178 }
179 Err(RemoteGitError::Exit {
180 op: "rev-parse --show-toplevel",
181 code: run.code.unwrap_or(-1),
182 stderr: stderr.trim_end().to_string(),
183 })
184}
185
186pub fn context(
191 endpoint: &RemoteEndpoint,
192 workdir: &Path,
193 cancel: &CancelToken,
194) -> Result<GitContext, RemoteGitError> {
195 let exec = GitExec::Remote {
196 endpoint: endpoint.clone(),
197 workdir,
198 };
199 let repo = RepoTarget::Remote {
200 endpoint: endpoint.clone(),
201 workdir: workdir.to_path_buf(),
202 };
203 context_with(&exec, repo, cancel)
204}
205
206pub(crate) fn context_with(
210 exec: &GitExec,
211 repo: RepoTarget,
212 cancel: &CancelToken,
213) -> Result<GitContext, RemoteGitError> {
214 let head_run = exec.run(
216 &[
217 "rev-parse".into(),
218 "--verify".into(),
219 "--quiet".into(),
220 "HEAD".into(),
221 ],
222 cancel,
223 )?;
224 let branch_run = exec.run(
227 &["symbolic-ref".into(), "--short".into(), "HEAD".into()],
228 cancel,
229 )?;
230 let config_run = exec.run(
232 &[
233 "config".into(),
234 "-z".into(),
235 "--get-regexp".into(),
236 "^remote\\.[^.]+\\.url$".into(),
237 ],
238 cancel,
239 )?;
240 context_from_runs(repo, &head_run, &branch_run, &config_run)
241}
242
243pub(crate) fn context_from_runs(
246 repo: RepoTarget,
247 head_run: &GitRun,
248 branch_run: &GitRun,
249 config_run: &GitRun,
250) -> Result<GitContext, RemoteGitError> {
251 Ok(GitContext {
252 repo,
253 head_sha: head_sha_from_run(head_run)?,
254 head_branch: head_branch_from_run(branch_run)?,
255 remotes: remotes_from_run(config_run)?,
256 })
257}
258
259fn head_sha_from_run(run: &GitRun) -> Result<Option<String>, RemoteGitError> {
260 if run.success {
261 return parse_sha(&run.stdout, "rev-parse HEAD").map(Some);
262 }
263 if run.code == Some(1) {
264 return Ok(None);
265 }
266 Err(RemoteGitError::Exit {
267 op: "rev-parse HEAD",
268 code: run.code.unwrap_or(-1),
269 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
270 })
271}
272
273fn head_branch_from_run(run: &GitRun) -> Result<Option<String>, RemoteGitError> {
274 if run.success {
275 let name = String::from_utf8_lossy(&run.stdout).trim().to_string();
276 return Ok((!name.is_empty()).then_some(name));
277 }
278 if run.code == Some(128) {
279 return Ok(None);
280 }
281 Err(RemoteGitError::Exit {
282 op: "symbolic-ref HEAD",
283 code: run.code.unwrap_or(-1),
284 stderr: String::from_utf8_lossy(&run.stderr).trim_end().to_string(),
285 })
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct FileContents {
294 pub head: Option<Vec<u8>>,
295 pub index: Option<Vec<u8>>,
296}
297
298pub fn gutter(
304 endpoint: &RemoteEndpoint,
305 workdir: &Path,
306 head_sha: Option<&str>,
307 rel: &Path,
308 text: &str,
309 cancel: &CancelToken,
310) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), RemoteGitError> {
311 let contents = file_contents(endpoint, workdir, head_sha, rel, cancel)?;
312 let head = contents
313 .head
314 .as_deref()
315 .map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("HEAD blob")))
316 .transpose()?;
317 let index = contents
318 .index
319 .as_deref()
320 .map(|bytes| std::str::from_utf8(bytes).map_err(|_| RemoteGitError::Utf8("index blob")))
321 .transpose()?;
322 gutter_from_contents(head, index, text, rel)
323 .map_err(|error| RemoteGitError::Exec(error.to_string()))
324}
325
326pub fn file_contents(
331 endpoint: &RemoteEndpoint,
332 workdir: &Path,
333 head_sha: Option<&str>,
334 rel: &Path,
335 cancel: &CancelToken,
336) -> Result<FileContents, RemoteGitError> {
337 let exec = GitExec::Remote {
338 endpoint: endpoint.clone(),
339 workdir,
340 };
341 let head = match head_sha {
342 Some(sha) => {
343 let stdout = records(
344 &exec,
345 "ls-tree HEAD",
346 &[
347 "ls-tree".into(),
348 "-z".into(),
349 sha.into(),
350 "--".into(),
351 rel.as_os_str().into(),
352 ],
353 cancel,
354 )?;
355 match parse_tree_entry(&stdout)? {
356 Some((oid, path)) if path == rel => {
357 Some(blob_bytes(&exec, &oid, "HEAD blob", cancel)?)
358 }
359 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree HEAD")),
363 None => None,
364 }
365 }
366 None => None,
367 };
368 let stdout = records(
369 &exec,
370 "ls-files --stage",
371 &[
372 "ls-files".into(),
373 "-z".into(),
374 "--stage".into(),
375 "--".into(),
376 rel.as_os_str().into(),
377 ],
378 cancel,
379 )?;
380 let index = match parse_index_entry(&stdout)? {
381 Some((oid, path)) if path == rel => Some(blob_bytes(&exec, &oid, "index blob", cancel)?),
382 Some((_, _)) => return Err(RemoteGitError::Parse("ls-files --stage")),
383 None => None,
384 };
385 Ok(FileContents { head, index })
386}
387
388fn blob_bytes(
389 exec: &GitExec,
390 oid: &str,
391 what: &'static str,
392 cancel: &CancelToken,
393) -> Result<Vec<u8>, RemoteGitError> {
394 records(
395 exec,
396 what,
397 &["cat-file".into(), "-p".into(), oid.into()],
398 cancel,
399 )
400}
401
402pub fn commit_file_diff(
406 endpoint: &RemoteEndpoint,
407 workdir: &Path,
408 sha: &str,
409 rel: &Path,
410 cancel: &CancelToken,
411) -> Result<FileDiff, RemoteGitError> {
412 let exec = GitExec::Remote {
413 endpoint: endpoint.clone(),
414 workdir,
415 };
416 let stdout = records(
417 &exec,
418 "rev-list --parents",
419 &[
420 "rev-list".into(),
421 "--parents".into(),
422 "-n".into(),
423 "1".into(),
424 sha.into(),
425 ],
426 cancel,
427 )?;
428 let (commit, parent) = parse_commit_parents(&stdout)?;
429 if commit != sha {
430 return Err(RemoteGitError::Parse("rev-list --parents"));
431 }
432 let parent_blob = match parent.as_deref() {
433 Some(parent) => {
434 let stdout = records(
435 &exec,
436 "ls-tree parent",
437 &[
438 "ls-tree".into(),
439 "-z".into(),
440 parent.into(),
441 "--".into(),
442 rel.as_os_str().into(),
443 ],
444 cancel,
445 )?;
446 match parse_tree_entry(&stdout)? {
447 Some((oid, path)) if path == rel => {
448 Some(blob_bytes(&exec, &oid, "parent blob", cancel)?)
449 }
450 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree parent")),
451 None => None,
452 }
453 }
454 None => None,
455 };
456 let stdout = records(
457 &exec,
458 "ls-tree commit",
459 &[
460 "ls-tree".into(),
461 "-z".into(),
462 commit.clone().into(),
463 "--".into(),
464 rel.as_os_str().into(),
465 ],
466 cancel,
467 )?;
468 let commit_blob = match parse_tree_entry(&stdout)? {
469 Some((oid, path)) if path == rel => blob_bytes(&exec, &oid, "commit blob", cancel)?,
470 Some((_, _)) => return Err(RemoteGitError::Parse("ls-tree commit")),
471 None => return Err(RemoteGitError::Exec("no diff for path".into())),
472 };
473 let hunks = hunks_from_buffers(parent_blob.as_deref(), &commit_blob, rel)
474 .map_err(|error| RemoteGitError::Exec(error.to_string()))?;
475 Ok(FileDiff::from_hunks(rel.to_path_buf(), hunks))
476}
477
478pub fn effective_host(
484 endpoint: &RemoteEndpoint,
485 workdir: &Path,
486 remote: &crate::permalink::AliasRemote,
487 cancel: &CancelToken,
488) -> Result<String, crate::ssh::EffectiveHostError> {
489 use crate::ssh::EffectiveHostError;
490 let host = remote.host();
491 if !crate::permalink::is_safe_host(host) {
492 return Err(EffectiveHostError::InvalidHost);
493 }
494 let mut args = vec!["-G".into()];
495 if let Some(user) = &remote.user {
496 args.extend(["-l".into(), user.into()]);
497 }
498 if let Some(port) = remote.port {
499 args.extend(["-p".into(), port.to_string().into()]);
500 }
501 args.push(host.into());
502 let command = strop_remote::RemoteCommand::new("ssh", args, workdir)
503 .map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
504 let run = strop_remote::run(endpoint, &command, cancel)
505 .map_err(|error| EffectiveHostError::Spawn(error.to_string()))?;
506 if !run.status.success() {
507 return Err(EffectiveHostError::Failed(
508 String::from_utf8_lossy(&run.stderr).trim().to_string(),
509 ));
510 }
511 if run.stdout_dropped > 0 {
512 return Err(EffectiveHostError::Failed(
513 "output truncated before a hostname line".into(),
514 ));
515 }
516 let stdout = String::from_utf8_lossy(&run.stdout);
517 let hostname = parse_effective_hostname(&stdout).ok_or(EffectiveHostError::NoHostname)?;
518 if hostname == host && !hostname.contains('.') {
519 return Err(EffectiveHostError::Unresolved);
520 }
521 Ok(hostname)
522}
523
524fn parse_toplevel(bytes: &[u8]) -> Result<PathBuf, RemoteGitError> {
534 let trimmed = bytes.strip_suffix(b"\n").unwrap_or(bytes);
535 if trimmed.is_empty() || trimmed.first() != Some(&b'/') {
536 return Err(RemoteGitError::Parse("rev-parse --show-toplevel"));
537 }
538 Ok(bytes_to_path(trimmed))
539}
540
541fn parse_sha(bytes: &[u8], what: &'static str) -> Result<String, RemoteGitError> {
543 let text = String::from_utf8_lossy(bytes);
544 let sha = text.trim();
545 let valid = (40..=64).contains(&sha.len())
546 && !sha.is_empty()
547 && sha.bytes().all(|b| b.is_ascii_hexdigit());
548 if !valid {
549 return Err(RemoteGitError::Parse(what));
550 }
551 Ok(sha.to_string())
552}
553
554fn parse_remote_config(bytes: &[u8]) -> Result<Vec<(String, String)>, RemoteGitError> {
558 let mut remotes = Vec::new();
559 for record in bytes.split(|&b| b == 0) {
560 if record.is_empty() {
561 continue;
562 }
563 let Some((key, url)) = split_record(record, b'\n') else {
564 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
565 };
566 let key = std::str::from_utf8(key).map_err(|_| RemoteGitError::Utf8("remote name"))?;
567 let url = std::str::from_utf8(url).map_err(|_| RemoteGitError::Utf8("remote url"))?;
568 let Some(name) = key
569 .strip_prefix("remote.")
570 .and_then(|rest| rest.strip_suffix(".url"))
571 else {
572 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
573 };
574 if name.is_empty() {
575 return Err(RemoteGitError::Parse("config --get-regexp remote.*.url"));
576 }
577 remotes.push((name.to_string(), url.to_owned()));
578 }
579 Ok(remotes)
580}
581
582fn parse_tree_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
586 let Some(record) = first_record(bytes) else {
587 return Ok(None);
588 };
589 let Some((meta, path)) = split_record(record, b'\t') else {
590 return Err(RemoteGitError::Parse("ls-tree"));
591 };
592 let mut fields = meta.split(|&b| b == b' ');
593 let oid = fields.nth(2).ok_or(RemoteGitError::Parse("ls-tree"))?;
594 let oid = parse_sha(oid, "ls-tree oid")?;
595 Ok(Some((oid, bytes_to_path(path))))
596}
597
598fn parse_index_entry(bytes: &[u8]) -> Result<Option<(String, PathBuf)>, RemoteGitError> {
600 let Some(record) = first_record(bytes) else {
601 return Ok(None);
602 };
603 let Some((meta, path)) = split_record(record, b'\t') else {
604 return Err(RemoteGitError::Parse("ls-files --stage"));
605 };
606 let mut fields = meta.split(|&b| b == b' ');
607 let oid = fields
608 .nth(1)
609 .ok_or(RemoteGitError::Parse("ls-files --stage"))?;
610 let oid = parse_sha(oid, "ls-files oid")?;
611 Ok(Some((oid, bytes_to_path(path))))
612}
613
614fn parse_commit_parents(bytes: &[u8]) -> Result<(String, Option<String>), RemoteGitError> {
618 let text = String::from_utf8_lossy(bytes);
619 let mut shas = text
620 .split_whitespace()
621 .map(|token| parse_sha(token.as_bytes(), "rev-list --parents"));
622 let commit = shas
623 .next()
624 .ok_or(RemoteGitError::Parse("rev-list --parents"))??;
625 let parent = shas.next().transpose()?;
626 Ok((commit, parent))
627}
628
629fn first_record(bytes: &[u8]) -> Option<&[u8]> {
630 bytes.split(|&b| b == 0).find(|record| !record.is_empty())
631}
632
633fn split_record(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> {
634 let index = bytes.iter().position(|&byte| byte == separator)?;
635 Some((&bytes[..index], &bytes[index + 1..]))
636}
637
638#[cfg(unix)]
639fn bytes_to_path(bytes: &[u8]) -> PathBuf {
640 use std::os::unix::ffi::OsStrExt;
641 PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
642}
643
644#[cfg(not(unix))]
645fn bytes_to_path(bytes: &[u8]) -> PathBuf {
646 PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
658 fn toplevel_is_absolute_native_path() {
659 assert_eq!(
660 parse_toplevel(b"/srv/proj with space\n").unwrap(),
661 PathBuf::from("/srv/proj with space")
662 );
663 assert!(
664 parse_toplevel(b"srv/proj\n").is_err(),
665 "relative is refused"
666 );
667 assert!(parse_toplevel(b"").is_err(), "empty is refused");
668 }
669
670 #[test]
671 fn shas_are_validated_object_names() {
672 assert_eq!(
673 parse_sha(b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45\n", "x").unwrap(),
674 "c59d8ceb7aeb96a1cdccff5646ec485acce32d45"
675 );
676 assert!(parse_sha(b"main\n", "x").is_err());
677 assert!(parse_sha(b"head is at 1234\n", "x").is_err());
678 assert!(parse_sha(b"\n", "x").is_err());
679 }
680
681 #[test]
684 fn remote_config_records_parse_native() {
685 let bytes = b"remote.origin.url\nhttps://example.com/acme/demo.git\0remote.up.url\ngit@gh:acme/other.git\0";
686 assert_eq!(
687 parse_remote_config(bytes).unwrap(),
688 vec![
689 (
690 "origin".to_string(),
691 "https://example.com/acme/demo.git".to_string()
692 ),
693 ("up".to_string(), "git@gh:acme/other.git".to_string()),
694 ]
695 );
696 assert_eq!(
697 parse_remote_config(b"").unwrap(),
698 Vec::<(String, String)>::new()
699 );
700 assert!(parse_remote_config(b"not-a-pair\0").is_err());
701 assert!(
702 parse_remote_config(b"remote..url\nx\0").is_err(),
703 "empty name"
704 );
705 assert!(
706 parse_remote_config(b"remote.o.url\nhttps://a/\xff\xfe\0").is_err(),
707 "non-UTF-8 url refused"
708 );
709 }
710
711 #[test]
715 fn tree_and_index_records_keep_native_paths() {
716 let tree = b"100644 blob 45b983be36b73c0788dc9cbcb76cbb80fc7bb057\tsrc/a b.rs\0";
717 let (oid, path) = parse_tree_entry(tree).unwrap().unwrap();
718 assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
719 assert_eq!(path, PathBuf::from("src/a b.rs"));
720 assert_eq!(parse_tree_entry(b"").unwrap(), None);
721
722 let index = b"100644 45b983be36b73c0788dc9cbcb76cbb80fc7bb057 0\tsrc/a b.rs\0";
723 let (oid, path) = parse_index_entry(index).unwrap().unwrap();
724 assert_eq!(oid, "45b983be36b73c0788dc9cbcb76cbb80fc7bb057");
725 assert_eq!(path, PathBuf::from("src/a b.rs"));
726 assert_eq!(parse_index_entry(b"").unwrap(), None);
727 assert!(parse_tree_entry(b"garbage\0").is_err());
728 assert!(parse_index_entry(b"100644 noshahere 0\tx\0").is_err());
729 }
730
731 #[test]
734 fn commit_parents_root_and_merged() {
735 let root = b"60209d7ce72dddfafc1caacd511325c314a083bf\n";
736 assert_eq!(
737 parse_commit_parents(root).unwrap(),
738 ("60209d7ce72dddfafc1caacd511325c314a083bf".to_string(), None)
739 );
740 let merged = b"c59d8ceb7aeb96a1cdccff5646ec485acce32d45 aaaa1111111111111111111111111111111111111 bbbb2222222222222222222222222222222222222\n";
741 let (child, parent) = parse_commit_parents(merged).unwrap();
742 assert_eq!(child, "c59d8ceb7aeb96a1cdccff5646ec485acce32d45");
743 assert_eq!(
744 parent.as_deref(),
745 Some("aaaa1111111111111111111111111111111111111")
746 );
747 assert!(parse_commit_parents(b"not-a-sha\n").is_err());
748 assert!(parse_commit_parents(b"").is_err());
749 }
750
751 #[test]
755 fn nonzero_exit_is_a_typed_error() {
756 let run = GitRun {
757 success: false,
758 code: Some(128),
759 stdout: Vec::new(),
760 stderr: b"fatal: unsafe repository\n".to_vec(),
761 stdout_dropped: 0,
762 stderr_dropped: 0,
763 };
764 match exit_or_bytes("rev-parse --show-toplevel", &run) {
765 Err(RemoteGitError::Exit { op, code, stderr }) => {
766 assert_eq!(op, "rev-parse --show-toplevel");
767 assert_eq!(code, 128);
768 assert_eq!(stderr, "fatal: unsafe repository");
769 }
770 other => panic!("expected Exit, got {other:?}"),
771 }
772 }
773
774 #[test]
775 fn no_matching_remote_config_is_data_but_real_failure_is_not() {
776 let mut run = GitRun {
777 success: false,
778 code: Some(1),
779 stdout: Vec::new(),
780 stderr: Vec::new(),
781 stdout_dropped: 0,
782 stderr_dropped: 0,
783 };
784 assert!(remotes_from_run(&run).unwrap().is_empty());
785 run.code = Some(3);
786 assert!(matches!(
787 remotes_from_run(&run),
788 Err(RemoteGitError::Exit { code: 3, .. })
789 ));
790 run.code = Some(1);
791 run.stdout_dropped = 1;
792 assert!(matches!(
793 remotes_from_run(&run),
794 Err(RemoteGitError::Truncated { .. })
795 ));
796 }
797
798 #[test]
800 fn truncation_is_typed() {
801 let run = GitRun {
802 success: true,
803 code: Some(0),
804 stdout: Vec::new(),
805 stderr: Vec::new(),
806 stdout_dropped: 4096,
807 stderr_dropped: 0,
808 };
809 assert_eq!(
810 exit_or_bytes("ls-tree", &run),
811 Err(RemoteGitError::Truncated {
812 op: "ls-tree",
813 dropped: 4096
814 })
815 );
816 }
817}