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
35pub 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 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 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 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 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 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 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 pub fn git_branch_show_current(&self) -> Result<Option<String>> {
165 self.git_stdout_optional_string_trimmed(&["branch", "--show-current"])
166 }
167
168 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 pub path: String,
209 pub target_path: Option<String>,
210}
211
212impl GitStatusItem {
213 pub fn is_untracked(&self, paranoid: bool) -> bool {
216 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 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#[derive(Debug)]
309#[non_exhaustive]
310pub struct GitLogEntry {
311 pub commit: String, pub merge: Option<String>,
313 pub author: String,
314 pub date: String,
315 pub message: String,
316 }
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 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; 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 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 if parts_i == 1 && !line.starts_with("Merge") {
401 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 if line == "\n" {
429 } 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 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 }
449 _ => unreachable!(),
450 }
451 }
452 line.clear();
453 }
454 }
455}
456
457impl GitWorkingDir {
458 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 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 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 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 explain(anyhow!("{outputs}"))
600 }
601 }
602 }
603 }
604 }
605
606 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 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 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 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 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}