Skip to main content

ephemeris_release/
git.rs

1//! Git adapters for local release operations, served through libgit2.
2
3use 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
17/// The remote every release operation fetches from and pushes to.
18const ORIGIN: &str = "origin";
19
20/// A Git query or mutation failure.
21#[derive(Debug, thiserror::Error)]
22pub enum GitError {
23    /// The directory is not a Git repository or worktree libgit2 can open.
24    #[error("cannot open the Git repository at {}", path.display())]
25    Open {
26        /// The directory.
27        path: PathBuf,
28        /// The libgit2 failure.
29        #[source]
30        source: Git2Error,
31    },
32    /// The tag names could not be read.
33    #[error("cannot list tags")]
34    Tags(#[source] Git2Error),
35    /// A revision expression does not name a commit.
36    #[error("cannot resolve revision {revision:?}")]
37    Resolve {
38        /// The revision expression.
39        revision: String,
40        /// The libgit2 failure.
41        #[source]
42        source: Git2Error,
43    },
44    /// A reference exists but cannot be read or peeled to a commit.
45    #[error("cannot read reference {name:?}")]
46    Reference {
47        /// The full reference name.
48        name: String,
49        /// The libgit2 failure.
50        #[source]
51        source: Git2Error,
52    },
53    /// The ancestry of two commits could not be read.
54    #[error("cannot tell whether {ancestor} is an ancestor of {descendant}")]
55    Ancestry {
56        /// The possible ancestor.
57        ancestor: String,
58        /// The possible descendant.
59        descendant: String,
60        /// The libgit2 failure.
61        #[source]
62        source: Git2Error,
63    },
64    /// Two commits could not be compared.
65    #[error("cannot compare {paths:?} between {} and {to}", from.as_deref().unwrap_or("the root"))]
66    Diff {
67        /// The older revision, absent for the empty tree.
68        from: Option<String>,
69        /// The newer revision.
70        to: String,
71        /// The repository paths the comparison is limited to.
72        paths: Vec<PathBuf>,
73        /// The libgit2 failure.
74        #[source]
75        source: Git2Error,
76    },
77    /// The commits between two revisions could not be listed.
78    #[error(
79        "cannot list the commits that change {paths:?} between {} and {to}",
80        from.as_deref().unwrap_or("the root")
81    )]
82    History {
83        /// The older revision, absent for the whole history.
84        from: Option<String>,
85        /// The newer revision.
86        to: String,
87        /// The repository paths the commits are limited to.
88        paths: Vec<PathBuf>,
89        /// The libgit2 failure.
90        #[source]
91        source: Git2Error,
92    },
93    /// The named remote is not configured.
94    #[error("cannot find remote {name:?}")]
95    Remote {
96        /// The remote name.
97        name: String,
98        /// The libgit2 failure.
99        #[source]
100        source: Git2Error,
101    },
102    /// A fetch failed.
103    #[error("cannot fetch {refspec:?} from {remote:?}")]
104    Fetch {
105        /// The remote name.
106        remote: String,
107        /// The requested refspec.
108        refspec: String,
109        /// The libgit2 failure.
110        #[source]
111        source: Git2Error,
112    },
113    /// A disposable worktree could not be created.
114    #[error("cannot create a worktree at {}", path.display())]
115    WorktreeAdd {
116        /// The worktree directory.
117        path: PathBuf,
118        /// The libgit2 failure.
119        #[source]
120        source: Git2Error,
121    },
122    /// A disposable worktree could not be removed.
123    #[error("cannot remove the worktree at {}", path.display())]
124    WorktreeRemove {
125        /// The worktree directory.
126        path: PathBuf,
127        /// The libgit2 failure.
128        #[source]
129        source: Git2Error,
130    },
131    /// The worktree could not be pruned and its branch could not be deleted.
132    #[error(
133        "cannot prune the worktree at {} ({prune}), and cannot delete its branch: {branch}",
134        path.display()
135    )]
136    WorktreeRemoveFailed {
137        /// The worktree directory.
138        path: PathBuf,
139        /// The prune failure.
140        #[source]
141        prune: Git2Error,
142        /// The branch deletion failure.
143        branch: Git2Error,
144    },
145    /// Worktree changes could not be staged.
146    #[error("cannot stage changes in {}", worktree.display())]
147    Stage {
148        /// The worktree.
149        worktree: PathBuf,
150        /// The libgit2 failure.
151        #[source]
152        source: Git2Error,
153    },
154    /// The staged changes could not be committed.
155    #[error("cannot commit in {}", worktree.display())]
156    Commit {
157        /// The worktree.
158        worktree: PathBuf,
159        /// The libgit2 failure.
160        #[source]
161        source: Git2Error,
162    },
163    /// An annotated tag could not be created.
164    #[error("cannot create tag {tag:?}")]
165    Tag {
166        /// The tag name.
167        tag: String,
168        /// The libgit2 failure.
169        #[source]
170        source: Git2Error,
171    },
172    /// The remote reference heads could not be listed.
173    #[error("cannot list the references of remote {remote:?}")]
174    List {
175        /// The remote name.
176        remote: String,
177        /// The libgit2 failure.
178        #[source]
179        source: Git2Error,
180    },
181    /// The remote branch moved away from the head the push expected.
182    #[error("remote {reference} is at {actual:?}, expected {expected:?}")]
183    StaleLease {
184        /// The full remote reference name.
185        reference: String,
186        /// The head the push was based on; `None` requires the branch to be
187        /// absent.
188        expected: Option<String>,
189        /// The head on the remote; `None` when the branch is absent.
190        actual: Option<String>,
191    },
192    /// The push could not be sent.
193    #[error("cannot push {reference}")]
194    Push {
195        /// The full remote reference name.
196        reference: String,
197        /// The libgit2 failure.
198        #[source]
199        source: Git2Error,
200    },
201    /// The remote refused a reference update.
202    #[error("the remote rejected the update of {reference}: {status}")]
203    Rejected {
204        /// The full remote reference name.
205        reference: String,
206        /// The status the remote reported.
207        status: String,
208    },
209}
210
211/// The repository paths whose changes count for a unit: everything under
212/// the `source` directories except the unit changelog.
213#[derive(Clone, Copy, Debug)]
214pub struct UnitScope<'a> {
215    /// Repository directories whose changes trigger a release.
216    pub source: &'a [RepositoryPath],
217    /// Repository path of the unit changelog, which never counts.
218    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    /// Whether the trees `old` and `new` differ in a path of this scope.
230    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
254/// Read access to repository history.
255pub trait GitHistory {
256    /// The failure of a history read.
257    type Error: StdError + Send + Sync + 'static;
258
259    /// Messages of the commits after `from` up to `to` that change `scope`,
260    /// oldest first. Without `from` the walk reaches the root commit. Merge
261    /// commits are left out.
262    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    /// Whether the first-parent chains from `base` to `first` and from `base`
270    /// to `second` have the same length and the same trees and messages.
271    /// A chain that does not reach `base` is equivalent to nothing.
272    fn equivalent(
273        &self,
274        repository: &Path,
275        base: &str,
276        first: &str,
277        second: &str,
278    ) -> Result<bool, Self::Error>;
279    /// Whether `scope` differs between the commits `from` and `to`. Without
280    /// `from` the comparison starts from the empty tree.
281    fn has_changes(
282        &self,
283        repository: &Path,
284        from: Option<&str>,
285        to: &str,
286        scope: UnitScope<'_>,
287    ) -> Result<bool, Self::Error>;
288    /// Whether `ancestor` is `descendant` or reachable from it.
289    fn is_ancestor(
290        &self,
291        repository: &Path,
292        ancestor: &str,
293        descendant: &str,
294    ) -> Result<bool, Self::Error>;
295    /// The commit a revision expression such as `origin/main` names.
296    fn resolve(&self, repository: &Path, rev: &str) -> Result<String, Self::Error>;
297    /// List the tag names that start with `prefix`.
298    fn tags(&self, repository: &Path, prefix: &str) -> Result<Vec<String>, Self::Error>;
299}
300
301/// Repository changes that maintain a release proposal branch.
302pub trait ProposalGit {
303    /// The failure of a branch update.
304    type Error: StdError + Send + Sync + 'static;
305
306    /// Commit staged changes, returning the new commit hash, or `None` when
307    /// nothing was staged.
308    fn commit_if_staged(
309        &self,
310        worktree: &Path,
311        message: &str,
312        author: Author<'_>,
313    ) -> Result<Option<String>, Self::Error>;
314    /// Update local references from `origin` as `refspec` maps them.
315    fn fetch(&self, repository: &Path, refspec: &str) -> Result<(), Self::Error>;
316    /// Like `fetch`, but succeed when the remote has no reference named by
317    /// `refspec`.
318    fn fetch_if_present(&self, repository: &Path, refspec: &str) -> Result<(), Self::Error>;
319    /// Push the worktree's `HEAD` to `branch`, failing if the remote branch
320    /// has moved away from `expected`.
321    fn push_with_lease(
322        &self,
323        worktree: &Path,
324        branch: &str,
325        expected: Option<&str>,
326    ) -> Result<(), Self::Error>;
327    /// Stage the changes at these worktree-relative paths, tracked or not.
328    fn stage_update(&self, worktree: &Path, paths: &[PathBuf]) -> Result<(), Self::Error>;
329    /// Create a disposable worktree at the not yet existing `path`, checked
330    /// out to `baseline` on a private branch.
331    fn worktree_add(
332        &self,
333        repository: &Path,
334        path: &Path,
335        baseline: &str,
336    ) -> Result<(), Self::Error>;
337    /// Remove a worktree created with [`ProposalGit::worktree_add`].
338    fn worktree_remove(&self, repository: &Path, path: &Path) -> Result<(), Self::Error>;
339}
340
341/// The HTTPS credential pushes and fetches authenticate with.
342#[derive(Clone, Debug)]
343pub struct RemoteCredentials {
344    username: String,
345    token: Token,
346}
347
348/// Production Git adapter backed by libgit2.
349#[derive(Clone, Debug, Default)]
350pub struct SystemGit {
351    credentials: Option<RemoteCredentials>,
352}
353
354impl RemoteCredentials {
355    /// Authenticate as `username` with `token`.
356    ///
357    /// Forges differ in the username they require beside a token.
358    #[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    /// An adapter that answers HTTPS authentication requests with
369    /// `credentials`.
370    #[must_use]
371    pub const fn authenticated(credentials: RemoteCredentials) -> Self {
372        Self {
373            credentials: Some(credentials),
374        }
375    }
376
377    /// Proxy settings taken from the Git configuration and environment, as
378    /// the `git` command line uses them.
379    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    /// The commit a revision expression names.
393    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    /// The worktree and private branch name for a worktree directory, derived
406    /// from its path so that removal finds what creation registered.
407    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    /// Callbacks that offer the configured credential once per connection,
422    /// so a rejected token fails instead of being retried forever.
423    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    /// Read the head of `reference` on `remote` right now.
441    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    /// Push `refspec` to `remote`, failing when the remote rejects an update.
461    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    /// Emulates `--force-with-lease` by reading the remote branch head
847    /// immediately before a forced push; a concurrent update between the
848    /// read and the push can still be overwritten.
849    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    /// A repository without any user configuration.
893    struct Repo(Repository);
894
895    impl Repo {
896        /// Initialize a repository on `main` at `path`.
897        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        /// Initialize a bare repository at `path`.
905        fn bare(path: &Path) -> Self {
906            Self(Repository::init_bare(path).unwrap())
907        }
908
909        /// Clone `branch` of the repository at `remote` into `path`.
910        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        /// Stage `paths` as `git add` does, including deletions.
919        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        /// Stage every change, as `git add --all` does, and commit it.
927        fn commit_all(&self, message: &str) -> String {
928            self.add(&["*"]);
929            self.commit(message)
930        }
931
932        /// Commit the index on `HEAD`.
933        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        /// Create a lightweight tag on `HEAD`.
959        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        /// Add the repository at `url` as `origin`.
965        fn add_origin(&self, url: &Path) {
966            self.0.remote("origin", url.to_str().unwrap()).unwrap();
967        }
968
969        /// Push `refspec` to `origin`.
970        fn push(&self, refspec: &str) {
971            self.0
972                .find_remote("origin")
973                .unwrap()
974                .push(&[refspec], None)
975                .unwrap();
976        }
977
978        /// The commit `revision` names.
979        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        /// The commit a full reference name points at, if it exists.
990        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        // Denying write access to the administrative worktrees directory
1358        // makes libgit2 fail to remove the worktree's metadata directory,
1359        // without preventing the branch lookup and deletion that follows.
1360        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}