1use std::cell::RefCell;
4use std::error::Error as StdError;
5use std::path::{Path, PathBuf};
6
7use ephemeris_files::RepositoryPath;
8use ephemeris_token::Token;
9use git2::{
10 BranchType, Commit, Cred, CredentialType, DiffOptions, Direction, Error as Git2Error,
11 ErrorCode, FetchOptions, Oid, ProxyOptions, PushOptions, Remote, RemoteCallbacks, RemoteHead,
12 Repository, Signature, Sort, Tree, WorktreeAddOptions, WorktreePruneOptions, message_prettify,
13};
14
15use crate::{Author, GitCommands, GitRefs};
16
17const ORIGIN: &str = "origin";
19
20#[derive(Debug, thiserror::Error)]
22pub enum GitError {
23 #[error("cannot open the Git repository at {}", path.display())]
25 Open {
26 path: PathBuf,
28 #[source]
30 source: Git2Error,
31 },
32 #[error("cannot list tags")]
34 Tags(#[source] Git2Error),
35 #[error("cannot resolve revision {revision:?}")]
37 Resolve {
38 revision: String,
40 #[source]
42 source: Git2Error,
43 },
44 #[error("cannot read reference {name:?}")]
46 Reference {
47 name: String,
49 #[source]
51 source: Git2Error,
52 },
53 #[error("cannot tell whether {ancestor} is an ancestor of {descendant}")]
55 Ancestry {
56 ancestor: String,
58 descendant: String,
60 #[source]
62 source: Git2Error,
63 },
64 #[error("cannot compare {paths:?} between {} and {to}", from.as_deref().unwrap_or("the root"))]
66 Diff {
67 from: Option<String>,
69 to: String,
71 paths: Vec<PathBuf>,
73 #[source]
75 source: Git2Error,
76 },
77 #[error(
79 "cannot list the commits that change {paths:?} between {} and {to}",
80 from.as_deref().unwrap_or("the root")
81 )]
82 History {
83 from: Option<String>,
85 to: String,
87 paths: Vec<PathBuf>,
89 #[source]
91 source: Git2Error,
92 },
93 #[error("cannot find remote {name:?}")]
95 Remote {
96 name: String,
98 #[source]
100 source: Git2Error,
101 },
102 #[error("cannot fetch {refspec:?} from {remote:?}")]
104 Fetch {
105 remote: String,
107 refspec: String,
109 #[source]
111 source: Git2Error,
112 },
113 #[error("cannot create a worktree at {}", path.display())]
115 WorktreeAdd {
116 path: PathBuf,
118 #[source]
120 source: Git2Error,
121 },
122 #[error("cannot remove the worktree at {}", path.display())]
124 WorktreeRemove {
125 path: PathBuf,
127 #[source]
129 source: Git2Error,
130 },
131 #[error(
133 "cannot prune the worktree at {} ({prune}), and cannot delete its branch: {branch}",
134 path.display()
135 )]
136 WorktreeRemoveFailed {
137 path: PathBuf,
139 #[source]
141 prune: Git2Error,
142 branch: Git2Error,
144 },
145 #[error("cannot stage changes in {}", worktree.display())]
147 Stage {
148 worktree: PathBuf,
150 #[source]
152 source: Git2Error,
153 },
154 #[error("cannot commit in {}", worktree.display())]
156 Commit {
157 worktree: PathBuf,
159 #[source]
161 source: Git2Error,
162 },
163 #[error("cannot create tag {tag:?}")]
165 Tag {
166 tag: String,
168 #[source]
170 source: Git2Error,
171 },
172 #[error("cannot list the references of remote {remote:?}")]
174 List {
175 remote: String,
177 #[source]
179 source: Git2Error,
180 },
181 #[error("remote {reference} is at {actual:?}, expected {expected:?}")]
183 StaleLease {
184 reference: String,
186 expected: Option<String>,
189 actual: Option<String>,
191 },
192 #[error("cannot push {reference}")]
194 Push {
195 reference: String,
197 #[source]
199 source: Git2Error,
200 },
201 #[error("the remote rejected the update of {reference}: {status}")]
203 Rejected {
204 reference: String,
206 status: String,
208 },
209}
210
211#[derive(Clone, Copy, Debug)]
214pub struct UnitScope<'a> {
215 pub source: &'a [RepositoryPath],
217 pub changelog: Option<&'a Path>,
219}
220
221impl UnitScope<'_> {
222 fn paths(self) -> Vec<PathBuf> {
223 self.source
224 .iter()
225 .map(|path| path.as_path().to_path_buf())
226 .collect()
227 }
228
229 fn differs(
231 self,
232 repo: &Repository,
233 old: Option<&Tree<'_>>,
234 new: &Tree<'_>,
235 ) -> Result<bool, Git2Error> {
236 let mut options = DiffOptions::new();
237 let is_root = |path: &RepositoryPath| {
238 path.as_path().as_os_str().is_empty() || path.as_path() == Path::new(".")
239 };
240 if !self.source.iter().any(is_root) {
241 for path in self.source {
242 options.pathspec(path.as_path());
243 }
244 }
245 let diff = repo.diff_tree_to_tree(old, Some(new), Some(&mut options))?;
246 Ok(diff.deltas().any(|delta| {
247 [delta.old_file(), delta.new_file()]
248 .iter()
249 .any(|file| file.path() != self.changelog)
250 }))
251 }
252}
253
254pub trait GitHistory {
256 type Error: StdError + Send + Sync + 'static;
258
259 fn commit_messages(
263 &self,
264 repository: &Path,
265 from: Option<&str>,
266 to: &str,
267 scope: UnitScope<'_>,
268 ) -> Result<Vec<String>, Self::Error>;
269 fn equivalent(
273 &self,
274 repository: &Path,
275 base: &str,
276 first: &str,
277 second: &str,
278 ) -> Result<bool, Self::Error>;
279 fn has_changes(
282 &self,
283 repository: &Path,
284 from: Option<&str>,
285 to: &str,
286 scope: UnitScope<'_>,
287 ) -> Result<bool, Self::Error>;
288 fn is_ancestor(
290 &self,
291 repository: &Path,
292 ancestor: &str,
293 descendant: &str,
294 ) -> Result<bool, Self::Error>;
295 fn resolve(&self, repository: &Path, rev: &str) -> Result<String, Self::Error>;
297 fn tags(&self, repository: &Path, prefix: &str) -> Result<Vec<String>, Self::Error>;
299}
300
301pub trait ProposalGit {
303 type Error: StdError + Send + Sync + 'static;
305
306 fn commit_if_staged(
309 &self,
310 worktree: &Path,
311 message: &str,
312 author: Author<'_>,
313 ) -> Result<Option<String>, Self::Error>;
314 fn fetch(&self, repository: &Path, refspec: &str) -> Result<(), Self::Error>;
316 fn fetch_if_present(&self, repository: &Path, refspec: &str) -> Result<(), Self::Error>;
319 fn push_with_lease(
322 &self,
323 worktree: &Path,
324 branch: &str,
325 expected: Option<&str>,
326 ) -> Result<(), Self::Error>;
327 fn stage_update(&self, worktree: &Path, paths: &[PathBuf]) -> Result<(), Self::Error>;
329 fn worktree_add(
332 &self,
333 repository: &Path,
334 path: &Path,
335 baseline: &str,
336 ) -> Result<(), Self::Error>;
337 fn worktree_remove(&self, repository: &Path, path: &Path) -> Result<(), Self::Error>;
339}
340
341#[derive(Clone, Debug)]
343pub struct RemoteCredentials {
344 username: String,
345 token: Token,
346}
347
348#[derive(Clone, Debug, Default)]
350pub struct SystemGit {
351 credentials: Option<RemoteCredentials>,
352}
353
354impl RemoteCredentials {
355 #[must_use]
359 pub fn new<U: Into<String>>(username: U, token: Token) -> Self {
360 Self {
361 username: username.into(),
362 token,
363 }
364 }
365}
366
367impl SystemGit {
368 #[must_use]
371 pub const fn authenticated(credentials: RemoteCredentials) -> Self {
372 Self {
373 credentials: Some(credentials),
374 }
375 }
376
377 fn proxy() -> ProxyOptions<'static> {
380 let mut options = ProxyOptions::new();
381 options.auto();
382 options
383 }
384
385 fn open(path: &Path) -> Result<Repository, GitError> {
386 Repository::open(path).map_err(|source| GitError::Open {
387 path: path.to_path_buf(),
388 source,
389 })
390 }
391
392 fn commit<'r>(repo: &'r Repository, rev: &str) -> Result<Commit<'r>, Git2Error> {
394 let object = repo.revparse_single(rev)?;
395 object.peel_to_commit()
396 }
397
398 fn origin(repo: &Repository) -> Result<Remote<'_>, GitError> {
399 repo.find_remote(ORIGIN).map_err(|source| GitError::Remote {
400 name: ORIGIN.to_owned(),
401 source,
402 })
403 }
404
405 fn worktree_name(path: &Path) -> String {
408 let mut name = String::from("ephemeris-worktree-");
409 for component in path.components().rev().take(2) {
410 name.extend(
411 component
412 .as_os_str()
413 .to_string_lossy()
414 .chars()
415 .filter(char::is_ascii_alphanumeric),
416 );
417 }
418 name
419 }
420
421 fn callbacks(&self) -> RemoteCallbacks<'_> {
424 let mut callbacks = RemoteCallbacks::new();
425 if let Some(credentials) = &self.credentials {
426 let mut offered = false;
427 callbacks.credentials(move |_, _, allowed| {
428 if offered || !allowed.contains(CredentialType::USER_PASS_PLAINTEXT) {
429 return Err(Git2Error::from_str(
430 "the configured forge token was rejected or cannot be used",
431 ));
432 }
433 offered = true;
434 Cred::userpass_plaintext(&credentials.username, credentials.token.expose())
435 });
436 }
437 callbacks
438 }
439
440 fn remote_head(
442 &self,
443 remote: &mut Remote<'_>,
444 reference: &str,
445 ) -> Result<Option<Oid>, GitError> {
446 let list_error = |source| GitError::List {
447 remote: ORIGIN.to_owned(),
448 source,
449 };
450 let connection = remote
451 .connect_auth(Direction::Push, Some(self.callbacks()), Some(Self::proxy()))
452 .map_err(list_error)?;
453 let heads = connection.list().map_err(list_error)?;
454 Ok(heads
455 .iter()
456 .find(|head| head.name() == reference)
457 .map(RemoteHead::oid))
458 }
459
460 fn push(
462 &self,
463 remote: &mut Remote<'_>,
464 refspec: &str,
465 reference: &str,
466 ) -> Result<(), GitError> {
467 let rejection = RefCell::new(None);
468 let mut callbacks = self.callbacks();
469 callbacks.push_update_reference(|name, status| {
470 if let Some(status) = status {
471 rejection
472 .borrow_mut()
473 .get_or_insert_with(|| (name.to_owned(), status.to_owned()));
474 }
475 Ok(())
476 });
477 let mut options = PushOptions::new();
478 options
479 .remote_callbacks(callbacks)
480 .proxy_options(Self::proxy());
481 remote
482 .push(&[refspec], Some(&mut options))
483 .map_err(|source| GitError::Push {
484 reference: reference.to_owned(),
485 source,
486 })?;
487 drop(options);
488 match rejection.into_inner() {
489 Some((rejected, status)) => Err(GitError::Rejected {
490 reference: rejected,
491 status,
492 }),
493 None => Ok(()),
494 }
495 }
496}
497
498impl GitCommands for SystemGit {
499 type Error = GitError;
500
501 fn authoritative_head(&self, repository: &Path, branch: &str) -> Result<String, Self::Error> {
502 self.resolve(repository, branch)
503 }
504
505 fn create_tag(
506 &self,
507 repository: &Path,
508 tag: &str,
509 commit: &str,
510 author: Author<'_>,
511 ) -> Result<(), Self::Error> {
512 let repo = Self::open(repository)?;
513 let tag_error = |source| GitError::Tag {
514 tag: tag.to_owned(),
515 source,
516 };
517 let target = Self::commit(&repo, commit).map_err(tag_error)?;
518 let tagger = Signature::now(author.name, author.email).map_err(tag_error)?;
519 let message = message_prettify(tag, None).map_err(tag_error)?;
520 repo.tag(tag, target.as_object(), &tagger, &message, false)
521 .map_err(tag_error)
522 .map(drop)
523 }
524
525 fn push_tag(&self, repository: &Path, tag: &str) -> Result<(), Self::Error> {
526 let repo = Self::open(repository)?;
527 let mut remote = Self::origin(&repo)?;
528 let reference = format!("refs/tags/{tag}");
529 self.push(&mut remote, &format!("{reference}:{reference}"), &reference)
530 }
531}
532
533impl GitRefs for SystemGit {
534 type Error = GitError;
535
536 fn reference(&self, repository: &Path, name: &str) -> Result<Option<String>, Self::Error> {
537 let repo = Self::open(repository)?;
538 let reference_error = |source| GitError::Reference {
539 name: name.to_owned(),
540 source,
541 };
542 let reference = match repo.find_reference(name) {
543 Ok(reference) => reference,
544 Err(error) if error.code() == ErrorCode::NotFound => return Ok(None),
545 Err(error) => return Err(reference_error(error)),
546 };
547 let commit = reference.peel_to_commit().map_err(reference_error)?;
548 Ok(Some(commit.id().to_string()))
549 }
550}
551
552impl GitHistory for SystemGit {
553 type Error = GitError;
554
555 fn tags(&self, repository: &Path, prefix: &str) -> Result<Vec<String>, Self::Error> {
556 let names = Self::open(repository)?
557 .tag_names(None)
558 .map_err(GitError::Tags)?;
559 Ok(names
560 .iter_bytes()
561 .filter_map(|name| str::from_utf8(name).ok())
562 .filter(|name| name.starts_with(prefix))
563 .map(str::to_owned)
564 .collect())
565 }
566
567 fn resolve(&self, repository: &Path, rev: &str) -> Result<String, Self::Error> {
568 let repo = Self::open(repository)?;
569 let commit = Self::commit(&repo, rev).map_err(|source| GitError::Resolve {
570 revision: rev.to_owned(),
571 source,
572 })?;
573 Ok(commit.id().to_string())
574 }
575
576 fn has_changes(
577 &self,
578 repository: &Path,
579 from: Option<&str>,
580 to: &str,
581 scope: UnitScope<'_>,
582 ) -> Result<bool, Self::Error> {
583 let repo = Self::open(repository)?;
584 let diff_error = |source| GitError::Diff {
585 from: from.map(str::to_owned),
586 to: to.to_owned(),
587 paths: scope.paths(),
588 source,
589 };
590 let tree = |rev: &str| {
591 repo.revparse_single(rev)
592 .and_then(|object| object.peel_to_tree())
593 .map_err(diff_error)
594 };
595 let old = from.map(tree).transpose()?;
596 let new = tree(to)?;
597 scope.differs(&repo, old.as_ref(), &new).map_err(diff_error)
598 }
599
600 fn commit_messages(
601 &self,
602 repository: &Path,
603 from: Option<&str>,
604 to: &str,
605 scope: UnitScope<'_>,
606 ) -> Result<Vec<String>, Self::Error> {
607 let repo = Self::open(repository)?;
608 let history_error = |source| GitError::History {
609 from: from.map(str::to_owned),
610 to: to.to_owned(),
611 paths: scope.paths(),
612 source,
613 };
614 let commit_id = |rev: &str| Self::commit(&repo, rev).map(|commit| commit.id());
615 let mut walk = repo.revwalk().map_err(history_error)?;
616 walk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)
617 .and_then(|()| walk.push(commit_id(to)?))
618 .and_then(|()| from.map_or(Ok(()), |from| walk.hide(commit_id(from)?)))
619 .map_err(history_error)?;
620 let mut messages = Vec::new();
621 for id in walk {
622 let commit = id
623 .and_then(|id| repo.find_commit(id))
624 .map_err(history_error)?;
625 if commit.parent_count() > 1 {
626 continue;
627 }
628 let parent = match commit.parent(0) {
629 Ok(parent) => Some(parent.tree().map_err(history_error)?),
630 Err(error) if error.code() == ErrorCode::NotFound => None,
631 Err(error) => return Err(history_error(error)),
632 };
633 let tree = commit.tree().map_err(history_error)?;
634 if scope
635 .differs(&repo, parent.as_ref(), &tree)
636 .map_err(history_error)?
637 {
638 messages.push(String::from_utf8_lossy(commit.message_bytes()).into_owned());
639 }
640 }
641 Ok(messages)
642 }
643
644 fn is_ancestor(
645 &self,
646 repository: &Path,
647 ancestor: &str,
648 descendant: &str,
649 ) -> Result<bool, Self::Error> {
650 let repo = Self::open(repository)?;
651 let commit = |revision: &str| {
652 Self::commit(&repo, revision)
653 .map(|commit| commit.id())
654 .map_err(|source| GitError::Resolve {
655 revision: revision.to_owned(),
656 source,
657 })
658 };
659 let (ancestor_id, descendant_id) = (commit(ancestor)?, commit(descendant)?);
660 if ancestor_id == descendant_id {
661 return Ok(true);
662 }
663 repo.graph_descendant_of(descendant_id, ancestor_id)
664 .map_err(|source| GitError::Ancestry {
665 ancestor: ancestor.to_owned(),
666 descendant: descendant.to_owned(),
667 source,
668 })
669 }
670
671 fn equivalent(
672 &self,
673 repository: &Path,
674 base: &str,
675 first: &str,
676 second: &str,
677 ) -> Result<bool, Self::Error> {
678 type Chain = Vec<(Oid, Vec<u8>)>;
679 let repo = Self::open(repository)?;
680 let commit = |revision: &str| {
681 Self::commit(&repo, revision).map_err(|source| GitError::Resolve {
682 revision: revision.to_owned(),
683 source,
684 })
685 };
686 let base = commit(base)?.id();
687 let chain = |head: &str| -> Result<Option<Chain>, GitError> {
688 let mut current = commit(head)?;
689 let mut chain = Vec::new();
690 while current.id() != base {
691 chain.push((current.tree_id(), current.message_bytes().to_vec()));
692 let Some(parent) = current.parent_ids().next() else {
693 return Ok(None);
694 };
695 current = commit(&parent.to_string())?;
696 }
697 Ok(Some(chain))
698 };
699 let (first, second) = (chain(first)?, chain(second)?);
700 Ok(first.is_some() && first == second)
701 }
702}
703
704impl ProposalGit for SystemGit {
705 type Error = GitError;
706
707 fn fetch_if_present(&self, repository: &Path, refspec: &str) -> Result<(), Self::Error> {
708 match self.fetch(repository, refspec) {
709 Err(GitError::Fetch {
710 source,
711 remote: _,
712 refspec: _,
713 }) if source.code() == ErrorCode::NotFound => Ok(()),
714 result => result,
715 }
716 }
717
718 fn fetch(&self, repository: &Path, refspec: &str) -> Result<(), Self::Error> {
719 let repo = Self::open(repository)?;
720 let mut origin = Self::origin(&repo)?;
721 let mut options = FetchOptions::new();
722 options
723 .remote_callbacks(self.callbacks())
724 .proxy_options(Self::proxy());
725 origin
726 .fetch(&[refspec], Some(&mut options), None)
727 .map_err(|source| GitError::Fetch {
728 remote: ORIGIN.to_owned(),
729 refspec: refspec.to_owned(),
730 source,
731 })
732 }
733
734 fn worktree_add(
735 &self,
736 repository: &Path,
737 path: &Path,
738 baseline: &str,
739 ) -> Result<(), Self::Error> {
740 let repo = Self::open(repository)?;
741 let add_error = |source| GitError::WorktreeAdd {
742 path: path.to_path_buf(),
743 source,
744 };
745 let commit = Self::commit(&repo, baseline).map_err(add_error)?;
746 let name = Self::worktree_name(path);
747 let mut branch = repo.branch(&name, &commit, true).map_err(add_error)?;
748 let mut options = WorktreeAddOptions::new();
749 options.reference(Some(branch.get()));
750 let result = repo.worktree(&name, path, Some(&options));
751 if let Err(source) = result {
752 drop(branch.delete());
753 return Err(add_error(source));
754 }
755 Ok(())
756 }
757
758 fn worktree_remove(&self, repository: &Path, path: &Path) -> Result<(), Self::Error> {
759 let repo = Self::open(repository)?;
760 let remove_error = |source| GitError::WorktreeRemove {
761 path: path.to_path_buf(),
762 source,
763 };
764 let name = Self::worktree_name(path);
765 let worktree = repo.find_worktree(&name).map_err(remove_error)?;
766 let prune = worktree.prune(Some(
767 WorktreePruneOptions::new()
768 .valid(true)
769 .locked(true)
770 .working_tree(true),
771 ));
772 let branch = repo
773 .find_branch(&name, BranchType::Local)
774 .and_then(|mut branch| branch.delete());
775 match (prune, branch) {
776 (Ok(()), Ok(())) => Ok(()),
777 (Err(prune), Err(branch)) => Err(GitError::WorktreeRemoveFailed {
778 path: path.to_path_buf(),
779 prune,
780 branch,
781 }),
782 (Err(source), Ok(())) | (Ok(()), Err(source)) => Err(remove_error(source)),
783 }
784 }
785
786 fn stage_update(&self, worktree: &Path, paths: &[PathBuf]) -> Result<(), Self::Error> {
787 let repo = Self::open(worktree)?;
788 let stage_error = |source| GitError::Stage {
789 worktree: worktree.to_path_buf(),
790 source,
791 };
792 let mut index = repo.index().map_err(stage_error)?;
793 for path in paths {
794 if worktree.join(path).symlink_metadata().is_ok() {
795 index.add_path(path).map_err(stage_error)?;
796 } else {
797 index.remove_path(path).map_err(stage_error)?;
798 }
799 }
800 index.write().map_err(stage_error)
801 }
802
803 fn commit_if_staged(
804 &self,
805 worktree: &Path,
806 message: &str,
807 author: Author<'_>,
808 ) -> Result<Option<String>, Self::Error> {
809 let repo = Self::open(worktree)?;
810 let commit_error = |source| GitError::Commit {
811 worktree: worktree.to_path_buf(),
812 source,
813 };
814 let tree = repo
815 .index()
816 .and_then(|mut index| index.write_tree())
817 .and_then(|id| repo.find_tree(id))
818 .map_err(commit_error)?;
819 let parent = match repo.head() {
820 Ok(head) => Some(head.peel_to_commit().map_err(commit_error)?),
821 Err(error) if error.code() == ErrorCode::UnbornBranch => None,
822 Err(error) => return Err(commit_error(error)),
823 };
824 let unchanged = parent
825 .as_ref()
826 .map_or_else(|| tree.is_empty(), |parent| parent.tree_id() == tree.id());
827 if unchanged {
828 return Ok(None);
829 }
830 let signature = Signature::now(author.name, author.email).map_err(commit_error)?;
831 let message = message_prettify(message, None).map_err(commit_error)?;
832 let parents: Vec<_> = parent.iter().collect();
833 let commit = repo
834 .commit(
835 Some("HEAD"),
836 &signature,
837 &signature,
838 &message,
839 &tree,
840 &parents,
841 )
842 .map_err(commit_error)?;
843 Ok(Some(commit.to_string()))
844 }
845
846 fn push_with_lease(
850 &self,
851 worktree: &Path,
852 branch: &str,
853 expected: Option<&str>,
854 ) -> Result<(), Self::Error> {
855 let repo = Self::open(worktree)?;
856 let mut remote = Self::origin(&repo)?;
857 let reference = format!("refs/heads/{branch}");
858 let actual = self
859 .remote_head(&mut remote, &reference)?
860 .map(|head| head.to_string());
861 if actual.as_deref() != expected {
862 return Err(GitError::StaleLease {
863 reference,
864 expected: expected.map(str::to_owned),
865 actual,
866 });
867 }
868 self.push(&mut remote, &format!("+HEAD:{reference}"), &reference)
869 }
870}
871
872#[cfg(test)]
873mod tests {
874 use std::fs;
875 use std::path::Path;
876
877 use ephemeris_files::RepositoryPath;
878 use ephemeris_token::Token;
879 use git2::build::RepoBuilder;
880 use git2::{IndexAddOption, Repository, RepositoryInitOptions, Signature};
881
882 use super::{
883 GitError, GitHistory as _, ProposalGit as _, RemoteCredentials, SystemGit, UnitScope,
884 };
885 use crate::{Author, GitCommands as _, GitRefs as _};
886
887 const AUTHOR: Author<'static> = Author {
888 name: "Release Bot",
889 email: "release@example.invalid",
890 };
891
892 struct Repo(Repository);
894
895 impl Repo {
896 fn init(path: &Path) -> Self {
898 let repository =
899 Repository::init_opts(path, RepositoryInitOptions::new().initial_head("main"))
900 .unwrap();
901 Self(repository)
902 }
903
904 fn bare(path: &Path) -> Self {
906 Self(Repository::init_bare(path).unwrap())
907 }
908
909 fn clone_branch(remote: &Path, branch: &str, path: &Path) -> Self {
911 let repository = RepoBuilder::new()
912 .branch(branch)
913 .clone(remote.to_str().unwrap(), path)
914 .unwrap();
915 Self(repository)
916 }
917
918 fn add(&self, paths: &[&str]) {
920 let mut index = self.0.index().unwrap();
921 index.add_all(paths, IndexAddOption::DEFAULT, None).unwrap();
922 index.update_all(paths, None).unwrap();
923 index.write().unwrap();
924 }
925
926 fn commit_all(&self, message: &str) -> String {
928 self.add(&["*"]);
929 self.commit(message)
930 }
931
932 fn commit(&self, message: &str) -> String {
934 let tree = self
935 .0
936 .find_tree(self.0.index().unwrap().write_tree().unwrap())
937 .unwrap();
938 let signature = Signature::now("Release test", "release-test@example.invalid").unwrap();
939 let parent = self
940 .0
941 .head()
942 .ok()
943 .map(|head| head.peel_to_commit().unwrap());
944 let parents: Vec<_> = parent.iter().collect();
945 self.0
946 .commit(
947 Some("HEAD"),
948 &signature,
949 &signature,
950 message,
951 &tree,
952 &parents,
953 )
954 .unwrap()
955 .to_string()
956 }
957
958 fn tag(&self, name: &str) {
960 let head = self.0.revparse_single("HEAD").unwrap();
961 self.0.tag_lightweight(name, &head, false).unwrap();
962 }
963
964 fn add_origin(&self, url: &Path) {
966 self.0.remote("origin", url.to_str().unwrap()).unwrap();
967 }
968
969 fn push(&self, refspec: &str) {
971 self.0
972 .find_remote("origin")
973 .unwrap()
974 .push(&[refspec], None)
975 .unwrap();
976 }
977
978 fn resolve(&self, revision: &str) -> String {
980 self.0
981 .revparse_single(revision)
982 .unwrap()
983 .peel_to_commit()
984 .unwrap()
985 .id()
986 .to_string()
987 }
988
989 fn reference(&self, name: &str) -> Option<String> {
991 self.0
992 .find_reference(name)
993 .ok()
994 .map(|reference| reference.peel_to_commit().unwrap().id().to_string())
995 }
996 }
997
998 fn fixture() -> (tempfile::TempDir, Repo) {
999 let root = tempfile::tempdir().unwrap();
1000 let repository = Repo::init(root.path());
1001 fs::write(root.path().join("README.md"), "fixture\n").unwrap();
1002 repository.commit_all("Initial");
1003 (root, repository)
1004 }
1005
1006 fn directories(paths: &[&str]) -> Vec<RepositoryPath> {
1007 paths.iter().map(|path| path.parse().unwrap()).collect()
1008 }
1009
1010 fn scope(source: &[RepositoryPath]) -> UnitScope<'_> {
1011 UnitScope {
1012 source,
1013 changelog: Some(Path::new("app/CHANGELOG.md")),
1014 }
1015 }
1016
1017 #[test]
1018 fn tags_lists_only_matching_names_by_prefix() {
1019 let (root, repository) = fixture();
1020 repository.tag("app-v1.0.0");
1021 repository.tag("app-v1.1.0");
1022 repository.tag("lib-v1.0.0");
1023 let mut tags = SystemGit::default().tags(root.path(), "app-v").unwrap();
1024 tags.sort();
1025 assert_eq!(tags, ["app-v1.0.0", "app-v1.1.0"]);
1026 }
1027
1028 #[test]
1029 fn credentials_keep_the_username_and_redact_the_token() {
1030 let credentials = RemoteCredentials::new("oauth2", Token::new("secret"));
1031 assert_eq!(credentials.username, "oauth2");
1032 assert!(!format!("{credentials:?}").contains("secret"));
1033 }
1034
1035 #[test]
1036 fn has_changes_limits_the_comparison_to_a_directory() {
1037 let (root, repository) = fixture();
1038 let git_adapter = SystemGit::default();
1039 let before = git_adapter.resolve(root.path(), "HEAD").unwrap();
1040 fs::create_dir_all(root.path().join("app/src")).unwrap();
1041 fs::write(root.path().join("app/src/main.txt"), "changed\n").unwrap();
1042 repository.commit_all("Change app");
1043 for (path, changed) in [
1044 ("app", true),
1045 ("app/src", true),
1046 ("lib", false),
1047 (".", true),
1048 ] {
1049 assert_eq!(
1050 git_adapter
1051 .has_changes(
1052 root.path(),
1053 Some(&before),
1054 "HEAD",
1055 scope(&directories(&[path]))
1056 )
1057 .unwrap(),
1058 changed,
1059 "{path:?}"
1060 );
1061 }
1062 }
1063
1064 #[test]
1065 fn commit_messages_list_the_commits_that_change_a_directory_oldest_first() {
1066 let (root, repository) = fixture();
1067 let git_adapter = SystemGit::default();
1068 repository.tag("app-v1.0.0");
1069 fs::create_dir_all(root.path().join("app")).unwrap();
1070 for (path, message) in [
1071 ("app/a.txt", "feat: first"),
1072 ("lib.txt", "fix: elsewhere"),
1073 ("app/b.txt", "fix: second\n\nBody."),
1074 ] {
1075 fs::write(root.path().join(path), message).unwrap();
1076 repository.commit_all(message);
1077 }
1078 let messages = |path: &str| {
1079 git_adapter
1080 .commit_messages(
1081 root.path(),
1082 Some("app-v1.0.0"),
1083 "HEAD",
1084 scope(&directories(&[path])),
1085 )
1086 .unwrap()
1087 };
1088 assert_eq!(messages("app"), ["feat: first", "fix: second\n\nBody."]);
1089 assert_eq!(messages(".").len(), 3);
1090 assert_eq!(messages("other"), Vec::<String>::new());
1091 }
1092
1093 #[test]
1094 fn without_a_baseline_the_walk_reaches_the_root_commit() {
1095 let (root, repository) = fixture();
1096 let git_adapter = SystemGit::default();
1097 fs::create_dir_all(root.path().join("app")).unwrap();
1098 fs::write(root.path().join("app/a.txt"), "a\n").unwrap();
1099 repository.commit_all("feat: first");
1100 let messages = |path: &str| {
1101 git_adapter
1102 .commit_messages(root.path(), None, "HEAD", scope(&directories(&[path])))
1103 .unwrap()
1104 };
1105 assert_eq!(messages("."), ["Initial", "feat: first"]);
1106 assert_eq!(messages("app"), ["feat: first"]);
1107 assert_eq!(messages("lib"), Vec::<String>::new());
1108 let changed = |path: &str| {
1109 git_adapter
1110 .has_changes(root.path(), None, "HEAD", scope(&directories(&[path])))
1111 .unwrap()
1112 };
1113 assert!(changed("app"));
1114 assert!(!changed("lib"));
1115 }
1116
1117 #[test]
1118 fn a_scope_with_several_directories_counts_a_change_in_any_of_them() {
1119 let (root, repository) = fixture();
1120 let git_adapter = SystemGit::default();
1121 let before = git_adapter.resolve(root.path(), "HEAD").unwrap();
1122 fs::create_dir_all(root.path().join("shared")).unwrap();
1123 fs::write(root.path().join("shared/a.txt"), "changed\n").unwrap();
1124 repository.commit_all("Change shared");
1125 for (paths, changed) in [
1126 (&["app", "shared"][..], true),
1127 (&["app", "lib"][..], false),
1128 (&["app", "."][..], true),
1129 ] {
1130 assert_eq!(
1131 git_adapter
1132 .has_changes(
1133 root.path(),
1134 Some(&before),
1135 "HEAD",
1136 scope(&directories(paths))
1137 )
1138 .unwrap(),
1139 changed,
1140 "{paths:?}"
1141 );
1142 }
1143 }
1144
1145 #[test]
1146 fn the_unit_changelog_is_neither_a_change_nor_a_listed_commit() {
1147 let (root, repository) = fixture();
1148 let git_adapter = SystemGit::default();
1149 repository.tag("app-v1.0.0");
1150 fs::create_dir_all(root.path().join("app")).unwrap();
1151 fs::write(root.path().join("app/CHANGELOG.md"), "notes\n").unwrap();
1152 repository.commit_all("docs: changelog only");
1153 let only_changelog = repository.resolve("HEAD");
1154 let changes = |to: &str| {
1155 git_adapter
1156 .has_changes(
1157 root.path(),
1158 Some("app-v1.0.0"),
1159 to,
1160 scope(&directories(&["app"])),
1161 )
1162 .unwrap()
1163 };
1164 let messages = || {
1165 git_adapter
1166 .commit_messages(
1167 root.path(),
1168 Some("app-v1.0.0"),
1169 "HEAD",
1170 scope(&directories(&["app"])),
1171 )
1172 .unwrap()
1173 };
1174 assert!(!changes(&only_changelog));
1175 assert_eq!(messages(), Vec::<String>::new());
1176 fs::write(root.path().join("app/main.txt"), "code\n").unwrap();
1177 fs::write(root.path().join("app/CHANGELOG.md"), "more notes\n").unwrap();
1178 repository.commit_all("feat: code and changelog");
1179 assert!(changes("HEAD"));
1180 assert_eq!(messages(), ["feat: code and changelog"]);
1181 }
1182
1183 #[test]
1184 fn without_a_changelog_every_file_in_the_source_counts() {
1185 let (root, repository) = fixture();
1186 let git_adapter = SystemGit::default();
1187 repository.tag("app-v1.0.0");
1188 fs::create_dir_all(root.path().join("app")).unwrap();
1189 fs::write(root.path().join("app/CHANGELOG.md"), "notes\n").unwrap();
1190 repository.commit_all("docs: changelog only");
1191 let source = directories(&["app"]);
1192 let scope = UnitScope {
1193 source: &source,
1194 changelog: None,
1195 };
1196 assert!(
1197 git_adapter
1198 .has_changes(root.path(), Some("app-v1.0.0"), "HEAD", scope)
1199 .unwrap()
1200 );
1201 }
1202
1203 #[test]
1204 fn a_tag_is_signed_by_the_given_author_without_user_configuration() {
1205 let (root, repository) = fixture();
1206 let head = repository.resolve("HEAD");
1207 SystemGit::default()
1208 .create_tag(root.path(), "app-v1.0.0", &head, AUTHOR)
1209 .unwrap();
1210 let repo = Repository::open(root.path()).unwrap();
1211 let tag = repo
1212 .find_reference("refs/tags/app-v1.0.0")
1213 .unwrap()
1214 .peel_to_tag()
1215 .unwrap();
1216 assert_eq!(tag.tagger().unwrap().name().unwrap(), "Release Bot");
1217 }
1218
1219 #[test]
1220 fn a_commit_is_an_ancestor_of_itself_and_of_its_descendants_only() {
1221 let (root, repository) = fixture();
1222 let first = repository.resolve("HEAD");
1223 fs::write(root.path().join("README.md"), "changed\n").unwrap();
1224 let second = repository.commit_all("Second");
1225 let adapter = SystemGit::default();
1226 assert!(adapter.is_ancestor(root.path(), &first, &second).unwrap());
1227 assert!(adapter.is_ancestor(root.path(), &second, &second).unwrap());
1228 assert!(!adapter.is_ancestor(root.path(), &second, &first).unwrap());
1229 }
1230
1231 #[test]
1232 fn resolve_and_reference_name_commits_and_peel_annotated_tags() {
1233 let (root, repository) = fixture();
1234 let git_adapter = SystemGit::default();
1235 let head = repository.resolve("HEAD");
1236 assert_eq!(git_adapter.resolve(root.path(), "HEAD").unwrap(), head);
1237 git_adapter
1238 .create_tag(root.path(), "app-v1.0.0", &head, AUTHOR)
1239 .unwrap();
1240 assert_eq!(
1241 git_adapter.resolve(root.path(), "app-v1.0.0").unwrap(),
1242 head
1243 );
1244 assert_eq!(
1245 git_adapter
1246 .reference(root.path(), "refs/tags/app-v1.0.0")
1247 .unwrap(),
1248 Some(head)
1249 );
1250 assert_eq!(
1251 git_adapter
1252 .reference(root.path(), "refs/tags/missing")
1253 .unwrap(),
1254 None
1255 );
1256 }
1257
1258 #[test]
1259 fn fetch_if_present_reports_an_unreachable_remote() {
1260 let git_adapter = SystemGit::default();
1261 let remote = tempfile::tempdir().unwrap();
1262 Repo::bare(remote.path());
1263 let (root, repository) = fixture();
1264 repository.add_origin(remote.path());
1265 repository.push("refs/heads/main:refs/heads/main");
1266 let spec = |branch: &str| format!("+refs/heads/{branch}:refs/remotes/origin/{branch}");
1267 for branch in ["main", "proposal"] {
1268 git_adapter
1269 .fetch_if_present(root.path(), &spec(branch))
1270 .unwrap();
1271 }
1272 fs::remove_dir_all(remote.path()).unwrap();
1273 assert!(matches!(
1274 git_adapter.fetch_if_present(root.path(), &spec("main")),
1275 Err(GitError::Fetch {
1276 remote: _,
1277 refspec: _,
1278 source: _
1279 })
1280 ));
1281 }
1282
1283 #[test]
1284 fn worktree_lifecycle_and_lease_push_round_trip() {
1285 let git_adapter = SystemGit::default();
1286 let remote = tempfile::tempdir().unwrap();
1287 Repo::bare(remote.path());
1288 let (root, repository) = fixture();
1289 repository.add_origin(remote.path());
1290 repository.push("refs/heads/main:refs/heads/main");
1291
1292 let worktree_dir = tempfile::tempdir().unwrap();
1293 let worktree_path = worktree_dir.path().join("wt");
1294 let head = git_adapter.resolve(root.path(), "HEAD").unwrap();
1295 git_adapter
1296 .worktree_add(root.path(), &worktree_path, &head)
1297 .unwrap();
1298 fs::write(worktree_path.join("generated.txt"), "content\n").unwrap();
1299 git_adapter
1300 .stage_update(&worktree_path, &["generated.txt".into()])
1301 .unwrap();
1302 let commit = git_adapter
1303 .commit_if_staged(&worktree_path, "release: update proposal", AUTHOR)
1304 .unwrap();
1305 assert!(commit.is_some());
1306 let worktree_repo = Repository::open(&worktree_path).unwrap();
1307 let committed = worktree_repo.head().unwrap().peel_to_commit().unwrap();
1308 assert_eq!(committed.author().name().unwrap(), "Release Bot");
1309 assert_eq!(
1310 committed.committer().email().unwrap(),
1311 "release@example.invalid"
1312 );
1313 assert!(
1314 git_adapter
1315 .commit_if_staged(&worktree_path, "release: update proposal", AUTHOR)
1316 .unwrap()
1317 .is_none()
1318 );
1319 git_adapter
1320 .push_with_lease(&worktree_path, "proposal", None)
1321 .unwrap();
1322 assert!(matches!(
1323 git_adapter.push_with_lease(&worktree_path, "proposal", None),
1324 Err(GitError::StaleLease {
1325 reference: _,
1326 expected: None,
1327 actual: Some(actual),
1328 }) if Some(actual.as_str()) == commit.as_deref()
1329 ));
1330 git_adapter
1331 .worktree_remove(root.path(), &worktree_path)
1332 .unwrap();
1333 assert!(!worktree_path.exists());
1334
1335 let clone = tempfile::tempdir().unwrap();
1336 Repo::clone_branch(remote.path(), "proposal", clone.path());
1337 assert_eq!(
1338 fs::read_to_string(clone.path().join("generated.txt")).unwrap(),
1339 "content\n"
1340 );
1341 }
1342
1343 #[test]
1344 fn worktree_remove_deletes_the_branch_even_when_pruning_fails() {
1345 use std::os::unix::fs::PermissionsExt as _;
1346
1347 let git_adapter = SystemGit::default();
1348 let (root, _repository) = fixture();
1349 let head = git_adapter.resolve(root.path(), "HEAD").unwrap();
1350 let worktree_dir = tempfile::tempdir().unwrap();
1351 let worktree_path = worktree_dir.path().join("wt");
1352 git_adapter
1353 .worktree_add(root.path(), &worktree_path, &head)
1354 .unwrap();
1355 let name = SystemGit::worktree_name(&worktree_path);
1356
1357 let admin_parent = root.path().join(".git/worktrees");
1361 fs::set_permissions(&admin_parent, fs::Permissions::from_mode(0o555)).unwrap();
1362 let result = git_adapter.worktree_remove(root.path(), &worktree_path);
1363 fs::set_permissions(&admin_parent, fs::Permissions::from_mode(0o755)).unwrap();
1364
1365 assert!(matches!(
1366 result,
1367 Err(GitError::WorktreeRemove { path: _, source: _ })
1368 ));
1369 assert_eq!(
1370 git_adapter
1371 .reference(root.path(), &format!("refs/heads/{name}"))
1372 .unwrap(),
1373 None
1374 );
1375 }
1376
1377 #[test]
1378 fn worktree_add_removes_the_branch_when_the_worktree_already_exists() {
1379 let git_adapter = SystemGit::default();
1380 let (root, _repository) = fixture();
1381 let head = git_adapter.resolve(root.path(), "HEAD").unwrap();
1382 let worktree_dir = tempfile::tempdir().unwrap();
1383 let worktree_path = worktree_dir.path().join("wt");
1384 fs::create_dir_all(&worktree_path).unwrap();
1385 fs::write(worktree_path.join("occupied.txt"), "occupied\n").unwrap();
1386
1387 let error = git_adapter
1388 .worktree_add(root.path(), &worktree_path, &head)
1389 .unwrap_err();
1390 assert!(matches!(
1391 error,
1392 GitError::WorktreeAdd { path: _, source: _ }
1393 ));
1394 let branch = SystemGit::worktree_name(&worktree_path);
1395 assert_eq!(
1396 git_adapter
1397 .reference(root.path(), &format!("refs/heads/{branch}"))
1398 .unwrap(),
1399 None
1400 );
1401 }
1402
1403 #[test]
1404 fn pushed_tags_reach_the_remote() {
1405 let git_adapter = SystemGit::default();
1406 let remote_dir = tempfile::tempdir().unwrap();
1407 let remote = Repo::bare(remote_dir.path());
1408 let (root, repository) = fixture();
1409 repository.add_origin(remote_dir.path());
1410 let head = repository.resolve("HEAD");
1411 git_adapter
1412 .create_tag(root.path(), "app-v1.0.0", &head, AUTHOR)
1413 .unwrap();
1414 git_adapter.push_tag(root.path(), "app-v1.0.0").unwrap();
1415 assert_eq!(remote.reference("refs/tags/app-v1.0.0"), Some(head));
1416 }
1417}