run_git/
git.rs

1use std::{
2    borrow::Cow,
3    ffi::{OsStr, OsString},
4    fmt::{Debug, Display},
5    io::{BufRead, BufReader, Read},
6    os::unix::prelude::OsStrExt,
7    path::{Path, PathBuf},
8    process::{Child, ChildStdout, ExitStatus},
9    sync::Arc,
10};
11
12use anyhow::{anyhow, bail, Context, Result};
13
14pub use crate::base_and_rel_path::BaseAndRelPath;
15use crate::{
16    command::{run, run_outputs, run_stdout, spawn, Capturing},
17    flattened::Flattened,
18    path_util::AppendToPath,
19    util::contains_bytes,
20};
21
22#[derive(Debug, Clone)]
23pub struct GitWorkingDir {
24    pub working_dir_path: Arc<PathBuf>,
25}
26
27impl From<PathBuf> for GitWorkingDir {
28    fn from(value: PathBuf) -> Self {
29        GitWorkingDir {
30            working_dir_path: Arc::new(value),
31        }
32    }
33}
34
35/// Execute the external "git" command with `base_path` as its current
36/// directory and with the given arguments. Returns true when git
37/// exited with code 0, false if 1; returns an error for other exit
38/// codes or errors.
39pub fn _git<S: AsRef<OsStr> + Debug>(
40    working_dir: &Path,
41    arguments: &[S],
42    quiet: bool,
43) -> Result<bool> {
44    run(
45        working_dir,
46        "git",
47        arguments,
48        &[("PAGER", "")],
49        &[0, 1],
50        if quiet {
51            Capturing::stdout()
52        } else {
53            Capturing::none()
54        },
55    )
56}
57
58pub fn git_clone<'s, P: AsRef<Path>, U: AsRef<OsStr>, SF: AsRef<OsStr>>(
59    parent_dir: P,
60    clone_opts: impl IntoIterator<Item = &'s str>,
61    url: U,
62    subdir_filename: SF,
63    quiet: bool,
64) -> Result<GitWorkingDir> {
65    let parent_dir = parent_dir.as_ref().to_owned();
66    let clone = OsString::from("clone");
67    let mut arguments = vec![&*clone];
68    for arg in clone_opts {
69        arguments.push(arg.as_ref());
70    }
71    arguments.push(url.as_ref());
72    arguments.push(subdir_filename.as_ref());
73    let done = _git(&parent_dir, &arguments, quiet)?;
74    if done {
75        Ok(GitWorkingDir {
76            working_dir_path: Arc::new(parent_dir.append(subdir_filename.as_ref())),
77        })
78    } else {
79        bail!("git clone failed, exited with code 1")
80    }
81}
82
83impl GitWorkingDir {
84    pub fn working_dir_path_ref(&self) -> &Path {
85        &self.working_dir_path
86    }
87
88    pub fn working_dir_path_arc(&self) -> Arc<PathBuf> {
89        self.working_dir_path.clone()
90    }
91
92    /// Execute the external "git" command with `base_path` as its current
93    /// directory and with the given arguments. Returns true when git
94    /// exited with code 0, false if 1; returns an error for other exit
95    /// codes or errors.
96    pub fn git<S: AsRef<OsStr> + Debug>(&self, arguments: &[S], quiet: bool) -> Result<bool> {
97        _git(self.working_dir_path_ref(), arguments, quiet)
98    }
99
100    /// Only succeeds if Git exited with code 0.
101    pub fn git_stdout<S: AsRef<OsStr> + Debug>(&self, arguments: &[S]) -> Result<Vec<u8>> {
102        run_stdout(
103            self.working_dir_path_ref(),
104            "git",
105            arguments,
106            &[("PAGER", "")],
107            &[0],
108        )
109        .map(|o| o.output.stdout)
110    }
111
112    /// Only succeeds if Git exited with one of the given exit codes,
113    /// returning truthy, too.
114    pub fn git_stdout_accepting<S: AsRef<OsStr> + Debug>(
115        &self,
116        arguments: &[S],
117        acceptable_status_codes: &[i32],
118    ) -> Result<(bool, Vec<u8>)> {
119        let o = run_stdout(
120            self.working_dir_path_ref(),
121            "git",
122            arguments,
123            &[("PAGER", "")],
124            acceptable_status_codes,
125        )?;
126        Ok((o.truthy, o.output.stdout))
127    }
128
129    /// Retrieve the output from a Git command as utf-8 decoded string,
130    /// with leading and trailing whitespace removed.
131    pub fn git_stdout_string_trimmed_accepting<S: AsRef<OsStr> + Debug>(
132        &self,
133        arguments: &[S],
134        acceptable_status_codes: &[i32],
135    ) -> Result<(bool, String)> {
136        let (truthy, bytes) = self.git_stdout_accepting(arguments, acceptable_status_codes)?;
137        let x = String::from_utf8(bytes)?;
138        Ok((truthy, x.trim().into()))
139    }
140
141    /// Retrieve the output from a Git command as utf-8 decoded string,
142    /// with leading and trailing whitespace removed.
143    pub fn git_stdout_string_trimmed<S: AsRef<OsStr> + Debug>(
144        &self,
145        arguments: &[S],
146    ) -> Result<String> {
147        let bytes: Vec<u8> = self.git_stdout(arguments)?;
148        let x = String::from_utf8(bytes)?;
149        Ok(x.trim().into())
150    }
151
152    /// Retrieve the output from a Git command as utf-8 decoded string,
153    /// with leading and trailing whitespace removed; return the empty
154    /// string as None.
155    pub fn git_stdout_optional_string_trimmed<S: AsRef<OsStr> + Debug>(
156        &self,
157        arguments: &[S],
158    ) -> Result<Option<String>> {
159        let x = self.git_stdout_string_trimmed(arguments)?;
160        Ok(if x.is_empty() { None } else { Some(x) })
161    }
162
163    /// Get the name of the checked-out branch, if any.
164    pub fn git_branch_show_current(&self) -> Result<Option<String>> {
165        self.git_stdout_optional_string_trimmed(&["branch", "--show-current"])
166    }
167
168    /// Get the name of the checked-out branch, if any.
169    pub fn git_describe<S: AsRef<OsStr> + Debug>(&self, arguments: &[S]) -> Result<String> {
170        let arguments: Vec<OsString> = arguments.iter().map(|v| v.as_ref().to_owned()).collect();
171        let all_args = [vec![OsString::from("describe")], arguments].flattened();
172        self.git_stdout_string_trimmed(&all_args)
173    }
174
175    pub fn get_head_commit_id(&self) -> Result<String> {
176        self.git_stdout_string_trimmed(&["rev-parse", "HEAD"])
177    }
178
179    pub fn git_ls_files(&self) -> Result<Vec<BaseAndRelPath>> {
180        let stdout = self.git_stdout(&["ls-files", "-z"])?;
181        let base_path = self.working_dir_path_arc();
182        stdout
183            .split(|b| *b == b'\0')
184            .map(|bytes| -> Result<_> {
185                let rel_path = std::str::from_utf8(bytes)
186                    .with_context(|| {
187                        anyhow!(
188                            "decoding git ls-files output as unicode from directory {:?}: {:?}",
189                            base_path.to_string_lossy(),
190                            String::from_utf8_lossy(bytes)
191                        )
192                    })?
193                    .into();
194                Ok(BaseAndRelPath {
195                    base_path: Some(base_path.clone()),
196                    rel_path,
197                })
198            })
199            .collect::<Result<Vec<_>>>()
200    }
201}
202
203#[derive(Debug)]
204pub struct GitStatusItem {
205    pub x: char,
206    pub y: char,
207    /// Could include "->" for symlinks
208    pub path: String,
209    pub target_path: Option<String>,
210}
211
212impl GitStatusItem {
213    /// If `paranoid` is true, only returns true if both x and y are
214    /// '?', otherwise if either is.
215    pub fn is_untracked(&self, paranoid: bool) -> bool {
216        // According to documentation and observation both are '?' at
217        // the same time, and never only one of them '?'. Which way to
218        // check?
219        if paranoid {
220            self.x == '?' && self.y == '?'
221        } else {
222            self.x == '?' || self.y == '?'
223        }
224    }
225}
226
227impl Display for GitStatusItem {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.write_fmt(format_args!("{}{}  {}", self.x, self.y, self.path))?;
230        if let Some(target_path) = &self.target_path {
231            f.write_fmt(format_args!("-> {}", target_path))?;
232        }
233        Ok(())
234    }
235}
236
237fn parse_git_status_record(line: &str) -> Result<GitStatusItem> {
238    let mut cs = line.chars();
239    let x = cs.next().ok_or_else(|| anyhow!("can't parse c0"))?;
240    let y = cs.next().ok_or_else(|| anyhow!("can't parse c1"))?;
241    let c2 = cs.next().ok_or_else(|| anyhow!("can't parse c2"))?;
242    if c2 != ' ' {
243        bail!("c2 {c2:?} is not space, x={x:?}, y={y:?}")
244    }
245    let path: String = cs.collect();
246    Ok(GitStatusItem {
247        x,
248        y,
249        path,
250        target_path: None,
251    })
252}
253
254impl GitWorkingDir {
255    pub fn git_status(&self) -> Result<Vec<GitStatusItem>> {
256        let decode_line = |line_bytes| {
257            std::str::from_utf8(line_bytes).with_context(|| {
258                anyhow!(
259                    "decoding git status output as unicode from directory {:?}: {:?}",
260                    self.working_dir_path,
261                    String::from_utf8_lossy(line_bytes)
262                )
263            })
264        };
265        let stdout = self.git_stdout(&["status", "-z"])?;
266        let mut output = Vec::new();
267        let mut lines = stdout.split(|b| *b == b'\0');
268        while let Some(line_bytes) = lines.next() {
269            if line_bytes.is_empty() {
270                // Happens if stdout is empty!
271                continue;
272            }
273            let line = decode_line(line_bytes)?;
274            let record = parse_git_status_record(line).with_context(|| {
275                anyhow!(
276                    "decoding git status output from directory {:?}: {:?}",
277                    self.working_dir_path,
278                    String::from_utf8_lossy(line_bytes)
279                )
280            })?;
281            if record.x == 'R' {
282                let line_bytes = lines.next().ok_or_else(|| {
283                    anyhow!(
284                        "missing git status target path entry after 'R' \
285                     for record {record:?}, \
286                     from directory {:?}: {:?}",
287                        self.working_dir_path,
288                        String::from_utf8_lossy(line_bytes)
289                    )
290                })?;
291                let line2 = decode_line(line_bytes)?;
292                output.push(GitStatusItem {
293                    x: record.x,
294                    y: record.y,
295                    path: record.path,
296                    target_path: Some(line2.into()),
297                });
298            } else {
299                output.push(record);
300            }
301        }
302        Ok(output)
303    }
304}
305
306/// A single entry returned by the `GitLogIterator` as returned from
307/// `git_log`.
308#[derive(Debug)]
309#[non_exhaustive]
310pub struct GitLogEntry {
311    pub commit: String, // [u8; 20] ?,
312    pub merge: Option<String>,
313    pub author: String,
314    pub date: String,
315    pub message: String,
316    // files? Ignore for now
317}
318
319pub trait ChildWaiter {
320    fn child_wait(&mut self) -> anyhow::Result<ExitStatus>;
321}
322
323impl ChildWaiter for Child {
324    fn child_wait(&mut self) -> anyhow::Result<ExitStatus> {
325        Ok(self.wait()?)
326    }
327}
328
329pub struct GitLogIterator<R: Read, C: ChildWaiter> {
330    child: C,
331    stdout: BufReader<R>,
332    // The "commit " line if it was read in the previous iteration
333    left_over: Option<String>,
334}
335
336impl<R: Read, C: ChildWaiter> Iterator for GitLogIterator<R, C> {
337    type Item = Result<GitLogEntry>;
338
339    fn next(&mut self) -> Option<Self::Item> {
340        let mut line = String::new();
341        const NUM_PARTS: usize = 5; // one for each field
342        let mut parts: [Option<String>; NUM_PARTS] = Default::default();
343        let parts_to_entry = |mut parts: [Option<String>; NUM_PARTS]| {
344            Some(Ok(GitLogEntry {
345                commit: parts[0].take().unwrap(),
346                merge: parts[1].take(),
347                author: parts[2].take().unwrap(),
348                date: parts[3].take().unwrap(),
349                message: parts[4].take().unwrap(),
350            }))
351        };
352        // Index for the part == field.
353        let mut parts_i = 0;
354        let try_finish = |parts_i: usize, parts: [Option<String>; NUM_PARTS]| {
355            if parts_i == NUM_PARTS || parts_i == NUM_PARTS - 1 {
356                parts_to_entry(parts)
357            } else if parts_i == 0 {
358                None
359            } else {
360                Some(Err(anyhow!(
361                    "unfinished entry reading from git log, parts_i = {parts_i}"
362                )))
363            }
364        };
365        loop {
366            let is_eof = if let Some(left_over) = self.left_over.take() {
367                line = left_over;
368                false
369            } else {
370                match self.stdout.read_line(&mut line) {
371                    Ok(num_bytes) => num_bytes == 0,
372                    Err(e) => return Some(Err(e).with_context(|| anyhow!("reading from git log"))),
373                }
374            };
375            if is_eof {
376                match (|| {
377                    let status = self.child.child_wait()?;
378                    if !status.success() {
379                        bail!("exited with non-success status {status:?}")
380                    }
381                    Ok(())
382                })() {
383                    Ok(()) => return try_finish(parts_i, parts),
384                    Err(e) => return Some(Err(e).with_context(|| anyhow!("finishing git log"))),
385                }
386            }
387            if line.starts_with("commit ") {
388                if parts_i == 0 {
389                    parts[parts_i] = Some(line["commit ".as_bytes().len()..].trim().to_string());
390                } else {
391                    self.left_over = Some(line);
392                    return try_finish(parts_i, parts);
393                }
394                parts_i += 1;
395            } else {
396                match parts_i {
397                    0 => unreachable!(),
398                    1 | 2 | 3 => {
399                        // Merge, Author and Date
400                        if parts_i == 1 && !line.starts_with("Merge") {
401                            // There is no Merge, just Author and Date
402                            parts_i += 1;
403                        }
404                        if let Some((key, val)) = line.split_once(':') {
405                            let (expected_key, valref) = match parts_i {
406                                1 => ("Merge", &mut parts[parts_i]),
407                                2 => ("Author", &mut parts[parts_i]),
408                                3 => ("Date", &mut parts[parts_i]),
409                                _ => unreachable!(),
410                            };
411                            if key != expected_key {
412                                return Some(Err(anyhow!(
413                                    "expected key {expected_key:?}, but got \
414                                     {key:?} from git log on line {parts_i}: {line:?}"
415                                )));
416                            }
417                            *valref = Some(val.trim().into());
418                        } else {
419                            return Some(Err(anyhow!(
420                                "expected `Key: val` on line {parts_i} \
421                                 of git log entry, got: {line:?}"
422                            )));
423                        }
424                        parts_i += 1;
425                    }
426                    4 => {
427                        // Commit message
428                        if line == "\n" {
429                            // ignore; sigh
430                        } else if let Some(rest) = line.strip_prefix("    ") {
431                            if parts[parts_i].is_none() {
432                                parts[parts_i] = Some(String::new());
433                            }
434                            let message = parts[parts_i].as_mut().unwrap();
435                            message.push_str(rest);
436                        } else if line.starts_with(':') {
437                            // ignore for now; but switch forward
438                            parts_i += 1;
439                        } else {
440                            return Some(Err(anyhow!(
441                                "expected commit message or `:...` on line {parts_i} \
442                                 of git log entry, got: {line:?}"
443                            )));
444                        }
445                    }
446                    5 => {
447                        // in ":" file part, ignore
448                    }
449                    _ => unreachable!(),
450                }
451            }
452            line.clear();
453        }
454    }
455}
456
457impl GitWorkingDir {
458    /// Git log will already receive appropriate formatting options
459    /// (`--raw` or `--format..`), don't give any!
460    pub fn git_log<S: AsRef<OsStr> + Debug>(
461        &self,
462        arguments: &[S],
463    ) -> Result<GitLogIterator<ChildStdout, Child>> {
464        let mut all_arguments: Vec<&OsStr> = vec![
465            OsStr::from_bytes("log".as_bytes()),
466            OsStr::from_bytes("--raw".as_bytes()),
467        ];
468        for arg in arguments {
469            all_arguments.push(arg.as_ref());
470        }
471        let mut child = spawn(
472            self.working_dir_path_ref(),
473            "git",
474            &all_arguments,
475            &[("PAGER", "")],
476            Capturing::stdout(),
477        )?;
478        let stdout = BufReader::new(child.stdout.take().expect("specified"));
479        Ok(GitLogIterator {
480            child,
481            stdout,
482            left_over: None,
483        })
484    }
485
486    /// Resolve the given reference. If `to_commit` is true, resolves
487    /// to a commit id. Returns None if the reference doesn't exist /
488    /// can't be resolved (details?). Note that it's not possible to
489    /// check commit ids for existence, use `git_cat_file` with
490    /// `GitCatFileMode::ShowExists` instead.
491    pub fn git_rev_parse(&self, name: &str, to_commit: bool) -> Result<Option<String>> {
492        let full_name: Cow<str> = if to_commit {
493            format!("{name}^{{commit}}").into()
494        } else {
495            name.into()
496        };
497        let outputs = run_outputs(
498            self.working_dir_path_ref(),
499            "git",
500            &["rev-parse", &full_name],
501            &[("PAGER", "")],
502            &[0, 128],
503        )?;
504        if outputs.truthy {
505            let stdout = std::str::from_utf8(&outputs.stdout)?;
506            let commit = stdout.trim();
507            if commit.is_empty() {
508                bail!("`git rev-parse {full_name:?}` returned the empty string")
509            }
510            Ok(Some(commit.into()))
511        } else if contains_bytes(&outputs.stderr, b": unknown revision") {
512            Ok(None)
513        } else {
514            bail!("`git rev-parse {full_name:?}`: {outputs}")
515        }
516    }
517
518    /// Create an annotated or signed Git tag. Returns whether the tag has
519    /// been created, `false` means the tag already exists on the same
520    /// commit (an error is returned if it exists on another commit). Does
521    /// not check whether the tag message is the same, though!
522    pub fn git_tag(
523        &self,
524        tag_name: &str,
525        revision: Option<&str>,
526        message: &str,
527        sign: bool,
528        local_user: Option<&str>,
529    ) -> Result<bool> {
530        let clean_local_user: String;
531        let mut args = vec![
532            "tag",
533            if sign { "-s" } else { "-a" },
534            tag_name,
535            "-m",
536            message,
537        ];
538        if let Some(local_user) = local_user {
539            // Meh, "GPG Keychain" (gpgtools.org) on macOS copies the
540            // fingerprint with non-breaking spaces to the clipboard.
541            clean_local_user = local_user
542                .chars()
543                .map(|c| if c == '\u{a0}' { ' ' } else { c })
544                .collect();
545            args.push("--local-user");
546            args.push(&clean_local_user);
547        }
548        if let Some(revision) = revision {
549            args.push(revision);
550        }
551
552        let explain = |e| {
553            let hint = if local_user.is_none() {
554                "-- NOTE: if you get 'gpg failed to sign the data', try giving the \
555             local-user argument"
556            } else {
557                ""
558            };
559            let base_path = self.working_dir_path_ref();
560            Err(e).with_context(|| anyhow!("running git {args:?} in {base_path:?}{hint}"))
561        };
562        match run_outputs(
563            self.working_dir_path_ref(),
564            "git",
565            &args,
566            &[("PAGER", "")],
567            &[0, 128],
568        ) {
569            Err(e) => explain(e),
570            Ok(outputs) => {
571                if outputs.truthy {
572                    Ok(true)
573                } else {
574                    if contains_bytes(&outputs.stderr, b"already exists") {
575                        let want_revision = revision.unwrap_or("HEAD");
576                        let want_commitid =
577                            self.git_rev_parse(want_revision, true)?.ok_or_else(|| {
578                                anyhow!("given revision {want_revision:?} does not resolve")
579                            })?;
580                        let existing_commitid =
581                            self.git_rev_parse(tag_name, true)?.ok_or_else(|| {
582                                anyhow!(
583                                    "`git tag ..` said tag {tag_name:?} already exists, \
584                                 but that name does not resolve"
585                                )
586                            })?;
587                        if want_commitid == existing_commitid {
588                            Ok(false)
589                        } else {
590                            bail!(
591                                "asked to create tag {tag_name:?} to commit {want_commitid:?}, \
592                             but that tag name already exists for commit {existing_commitid:?}"
593                            )
594                        }
595                    } else {
596                        // (How to make a proper error? Ideally `Outputs`
597                        // would hold it, created by the function that
598                        // returns it?) Hack:
599                        explain(anyhow!("{outputs}"))
600                    }
601                }
602            }
603        }
604    }
605
606    /// Get the name of the remote for the given branch
607    pub fn git_remote_get_default_for_branch(&self, branch_name: &str) -> Result<Option<String>> {
608        let config_key = format!("branch.{branch_name}.remote");
609        let (truthy, string) =
610            self.git_stdout_string_trimmed_accepting(&["config", "--get", &config_key], &[0, 1])?;
611        if truthy {
612            if string.is_empty() {
613                let base_path = self.working_dir_path_ref();
614                bail!(
615                    "the string returned by `git config --get {config_key:?}` \
616                 in {base_path:?} is empty"
617                )
618            } else {
619                Ok(Some(string))
620            }
621        } else {
622            Ok(None)
623        }
624    }
625
626    /// Push the given refspecs (like branch or tag names) to the given
627    /// repository (like remote name).
628    pub fn git_push<S: AsRef<OsStr> + Debug>(
629        &self,
630        repository: &str,
631        refspecs: &[S],
632        quiet: bool,
633    ) -> Result<()> {
634        let mut args: Vec<&OsStr> = vec!["push".as_ref(), repository.as_ref()];
635        for v in refspecs {
636            args.push(v.as_ref());
637        }
638        if !self.git(&args, quiet)? {
639            let base_path = self.working_dir_path_ref();
640            bail!("git {args:?} in {base_path:?} failed")
641        }
642        Ok(())
643    }
644}
645
646#[derive(Debug, Clone, Copy, PartialEq)]
647pub enum GitResetMode {
648    Soft,
649    Mixed,
650    Hard,
651    Merge,
652    Keep,
653}
654
655impl GitResetMode {
656    pub fn to_str(self) -> &'static str {
657        match self {
658            GitResetMode::Soft => "--soft",
659            GitResetMode::Mixed => "--mixed",
660            GitResetMode::Hard => "--hard",
661            GitResetMode::Merge => "--merge",
662            GitResetMode::Keep => "--keep",
663        }
664    }
665}
666
667impl Default for GitResetMode {
668    fn default() -> Self {
669        Self::Mixed
670    }
671}
672
673impl GitWorkingDir {
674    pub fn git_reset<S: AsRef<OsStr> + Debug>(
675        &self,
676        mode: GitResetMode,
677        options: &[S],
678        refspec: &str,
679        quiet: bool,
680    ) -> Result<()> {
681        let mut args: Vec<&OsStr> = vec!["reset".as_ref(), mode.to_str().as_ref()];
682        for opt in options {
683            args.push(opt.as_ref())
684        }
685        // Add *no* "--" before the refspec or it would mean a path!
686        args.push(refspec.as_ref());
687        self.git(&args, quiet)?;
688        Ok(())
689    }
690}
691
692#[derive(Debug, Clone, Copy, PartialEq)]
693pub enum GitObjectType {
694    Blob,
695    Tree,
696    Commit,
697    /// Annotated tag object (includes metadata and can point to any other object).
698    Tag,
699}
700impl GitObjectType {
701    pub fn to_str(self) -> &'static str {
702        match self {
703            GitObjectType::Blob => "blob",
704            GitObjectType::Tree => "tree",
705            GitObjectType::Commit => "commit",
706            GitObjectType::Tag => "tag",
707        }
708    }
709}
710
711#[derive(Debug, Clone, Copy, PartialEq)]
712pub enum GitCatFileMode {
713    ShowType,
714    ShowSize,
715    ShowExists,
716    ShowPretty,
717    Type(GitObjectType),
718}
719
720impl GitCatFileMode {
721    pub fn to_str(self) -> &'static str {
722        match self {
723            GitCatFileMode::ShowType => "-t",
724            GitCatFileMode::ShowSize => "-s",
725            GitCatFileMode::ShowExists => "-e",
726            GitCatFileMode::ShowPretty => "-p",
727            GitCatFileMode::Type(t) => t.to_str(),
728        }
729    }
730}
731
732impl GitWorkingDir {
733    pub fn git_cat_file(&self, mode: GitCatFileMode, object: &str) -> Result<bool> {
734        let args = &["cat-file", mode.to_str(), object];
735        self.git(args, true)
736    }
737
738    /// Shortcut for git_cat_file(GitCatFileMode::ShowExists, ..) for
739    /// easy reach
740    pub fn contains_reference(&self, object: &str) -> Result<bool> {
741        self.git_cat_file(GitCatFileMode::ShowExists, object)
742    }
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    struct NopWaiter;
750    impl ChildWaiter for NopWaiter {
751        fn child_wait(&mut self) -> anyhow::Result<ExitStatus> {
752            bail!("no wait")
753        }
754    }
755
756    fn gitlog_iterator_from_str<'s>(s: &'s str) -> impl Iterator<Item = Result<GitLogEntry>> + 's {
757        GitLogIterator {
758            child: NopWaiter,
759            stdout: BufReader::new(s.as_bytes()),
760            left_over: None,
761        }
762    }
763
764    fn t_gitlog_iterator(s: &str) -> Result<()> {
765        let mut it = gitlog_iterator_from_str(s);
766        let _r = it.next().unwrap()?;
767        Ok(())
768    }
769
770    #[test]
771    fn t0() -> Result<()> {
772        t_gitlog_iterator(
773            "commit afb6184585974a96688ec42c7f024118fcbc8d86
774Author: Christian Jaeger (Mac) <ch@christianjaeger.ch>
775Date:   Sun Apr 13 21:26:17 2025 +0200
776
777    regenerate index files via xmlhub
778    
779    version: 8.1
780
781commit 49a0c5ceed749fc4ec7a7798af56f19447977c56
782Author: Marcus Overwater <moverwater@ethz.ch>
783Date:   Thu Apr 3 14:59:34 2025 +0200
784
785    Added ReMASTER simulation xml
786
787commit 7ed02856897a8d2a8e9c8887ebf99e6e3c0c1cf7
788Author: Louis <louis.duplessis@bsse.ethz.ch>
789Date:   Thu Jun 6 11:41:55 2024 +0200
790
791    Initial commit
792",
793        )
794    }
795
796    #[test]
797    fn t1() -> Result<()> {
798        t_gitlog_iterator(
799            "commit 2a1e2fd51372cb1dba8d0b9ed076afa10ea53183
800Merge: 49a0c5c b995c14
801Author: Marcus Overwater <moverwater@ethz.ch>
802Date:   Thu Apr 17 11:25:21 2025 +0200
803
804    Merge branch 'master' of /Users/moverwater/xmlhub
805
806commit afb6184585974a96688ec42c7f024118fcbc8d86
807Author: Christian Jaeger (Mac) <ch@christianjaeger.ch>
808Date:   Sun Apr 13 21:26:17 2025 +0200
809
810    regenerate index files via xmlhub
811    
812    version: 8.1
813
814commit 49a0c5ceed749fc4ec7a7798af56f19447977c56
815Author: Marcus Overwater <moverwater@ethz.ch>
816Date:   Thu Apr 3 14:59:34 2025 +0200
817
818    Added ReMASTER simulation xml
819
820commit 7ed02856897a8d2a8e9c8887ebf99e6e3c0c1cf7
821Author: Louis <louis.duplessis@bsse.ethz.ch>
822Date:   Thu Jun 6 11:41:55 2024 +0200
823
824    Initial commit
825",
826        )
827    }
828}