Skip to main content

ephemeris_release/proposal/
branch.rs

1//! Mirrored proposal branch maintenance in a disposable worktree.
2
3use std::error::Error as StdError;
4use std::path::{Path, PathBuf};
5
6use super::ProposalError;
7use super::metadata::MarkerError;
8use crate::error::GitFailure;
9use crate::git::{GitHistory, ProposalGit};
10use crate::state::AuthorityBranch;
11use crate::{Author, GitRefs};
12
13/// Controls how a maintained proposal branch may be refreshed.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub(crate) enum SyncMode<'a> {
16    /// Refresh only when the pull request marker names the branch head.
17    Automatic { expected: Expectation<'a> },
18    /// Recreate the proposal from the authoritative branch, deliberately
19    /// replacing any existing proposal edits.
20    Regenerate,
21}
22
23/// What the pull request says about the proposal branch.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub(crate) enum Expectation<'a> {
26    /// The pull request marker names this head.
27    Marked(&'a str),
28    /// No pull request exists, so the branch must not exist yet or must
29    /// already be merged.
30    NoPullRequest,
31    /// The pull request exists but its marker cannot be read.
32    Unreadable(MarkerError),
33}
34
35impl Expectation<'_> {
36    fn check(self, branch_head: Option<&str>, merged: bool) -> Result<(), ProposalError> {
37        match (self, branch_head) {
38            (Self::Marked(expected), actual) if Some(expected) != actual => {
39                Err(ProposalError::ManualEdits {
40                    expected: expected.to_owned(),
41                    actual: actual.map(str::to_owned),
42                })
43            }
44            (Self::Unreadable(reason), Some(head)) => Err(ProposalError::UnreadableMarker {
45                head: head.to_owned(),
46                reason,
47            }),
48            (Self::NoPullRequest, Some(head)) if !merged => Err(ProposalError::UntrackedBranch {
49                head: head.to_owned(),
50            }),
51            _ => Ok(()),
52        }
53    }
54}
55
56/// Result of synchronizing a proposal branch.
57#[derive(Debug)]
58pub(crate) struct SyncResult {
59    /// Proposal branch head after the update.
60    pub(crate) proposal_head: String,
61}
62
63/// Where and how to maintain the proposal branch.
64#[derive(Clone, Copy, Debug)]
65pub(crate) struct BranchSync<'a> {
66    pub(crate) repository: &'a Path,
67    pub(crate) authority: &'a AuthorityBranch,
68    pub(crate) branch: &'a str,
69    pub(crate) author: Author<'a>,
70    pub(crate) mode: SyncMode<'a>,
71}
72
73/// Commits to the proposal worktree.
74pub(crate) struct Committer<'a, G> {
75    git: &'a G,
76    worktree: &'a Path,
77    author: Author<'a>,
78}
79
80impl<'a, G: ProposalGit> Committer<'a, G> {
81    pub(crate) const fn new(git: &'a G, worktree: &'a Path, author: Author<'a>) -> Self {
82        Self {
83            git,
84            worktree,
85            author,
86        }
87    }
88
89    /// Stage `paths`, so a later edit of the same file is recoverable.
90    pub(crate) fn stage(&self, paths: &[PathBuf]) -> Result<(), ProposalError> {
91        if !paths.is_empty() {
92            self.git
93                .stage_update(self.worktree, paths)
94                .map_err(GitFailure::new)?;
95        }
96        Ok(())
97    }
98
99    /// Stage `paths` and commit them with `subject`, returning the new
100    /// commit, or `None` when nothing is staged.
101    pub(crate) fn commit(
102        &self,
103        paths: &[PathBuf],
104        subject: &str,
105    ) -> Result<Option<String>, ProposalError> {
106        self.stage(paths)?;
107        Ok(self
108            .git
109            .commit_if_staged(self.worktree, subject, self.author)
110            .map_err(GitFailure::new)?)
111    }
112}
113
114/// Recreate the proposal on the authority tip: let `apply` edit a worktree
115/// of the tip and commit through the [`Committer`], then force-push with a
116/// lease on the branch head observed before the update.
117pub(crate) fn sync<G: GitHistory + GitRefs + ProposalGit>(
118    git: &G,
119    request: &BranchSync<'_>,
120    apply: impl FnOnce(&Path, &Committer<'_, G>) -> Result<(), ProposalError>,
121) -> Result<SyncResult, ProposalError> {
122    let BranchSync {
123        repository,
124        authority,
125        branch,
126        author: _,
127        mode: _,
128    } = *request;
129    let scratch = tempfile::tempdir().map_err(ProposalError::Scratch)?;
130    let worktree = scratch.path().join("proposal");
131    let worktree = worktree.as_path();
132    git.fetch(repository, &authority.refspec())
133        .map_err(GitFailure::new)?;
134    let authority_head = git
135        .resolve(repository, &authority.tracking())
136        .map_err(GitFailure::new)?;
137    let remote_branch = format!("refs/remotes/origin/{branch}");
138    git.fetch_if_present(repository, &format!("+refs/heads/{branch}:{remote_branch}"))
139        .map_err(GitFailure::new)?;
140    let branch_head = git
141        .reference(repository, &remote_branch)
142        .map_err(GitFailure::new)?;
143    git.worktree_add(repository, worktree, &authority_head)
144        .map_err(GitFailure::new)?;
145    let result = update(
146        git,
147        worktree,
148        request,
149        &authority_head,
150        branch_head.as_deref(),
151        apply,
152    );
153    if let Err(error) = git.worktree_remove(repository, worktree) {
154        let error: &dyn StdError = &error;
155        tracing::warn!(
156            error,
157            worktree = %worktree.display(),
158            "cannot remove the proposal worktree"
159        );
160    }
161    result.map(|proposal_head| SyncResult { proposal_head })
162}
163
164/// Apply the edit on the tip, commit, and push unless the branch already
165/// holds an equivalent proposal. In automatic mode, any branch the marker does
166/// not vouch for is refused.
167fn update<G: GitHistory + ProposalGit>(
168    git: &G,
169    worktree: &Path,
170    request: &BranchSync<'_>,
171    authority_head: &str,
172    branch_head: Option<&str>,
173    apply: impl FnOnce(&Path, &Committer<'_, G>) -> Result<(), ProposalError>,
174) -> Result<String, ProposalError> {
175    let committer = Committer::new(git, worktree, request.author);
176    apply(worktree, &committer)?;
177    let proposal_head = git.resolve(worktree, "HEAD").map_err(GitFailure::new)?;
178    let moved = proposal_head != authority_head;
179    if let Some(existing) = branch_head
180        && git
181            .equivalent(worktree, authority_head, existing, &proposal_head)
182            .map_err(GitFailure::new)?
183    {
184        return Ok(existing.to_owned());
185    }
186    if let SyncMode::Automatic { expected } = request.mode {
187        let merged = match (expected, branch_head) {
188            (Expectation::NoPullRequest, Some(head)) => git
189                .is_ancestor(worktree, head, authority_head)
190                .map_err(GitFailure::new)?,
191            _ => false,
192        };
193        expected.check(branch_head, merged)?;
194    }
195    if moved || branch_head.is_some() {
196        git.push_with_lease(worktree, request.branch, branch_head)
197            .map_err(GitFailure::new)?;
198    }
199    Ok(proposal_head)
200}
201
202#[cfg(test)]
203mod tests {
204    use std::cell::RefCell;
205    use std::path::{Path, PathBuf};
206    use std::sync::LazyLock;
207
208    use super::{BranchSync, Committer, Expectation, SyncMode, sync};
209    use crate::git::{GitHistory, ProposalGit, UnitScope};
210    use crate::proposal::ProposalError;
211    use crate::proposal::metadata::MarkerError;
212    use crate::state::AuthorityBranch;
213    use crate::{Author, GitRefs};
214
215    const AUTHOR: Author<'static> = Author {
216        name: "Release Bot",
217        email: "release@example.invalid",
218    };
219
220    static MAIN: LazyLock<AuthorityBranch> = LazyLock::new(|| "main".parse().unwrap());
221
222    const AUTHORITY: &str = "a000000000000000000000000000000000000000";
223
224    /// A scripted fake Git refusal.
225    #[derive(Debug, Eq, PartialEq, thiserror::Error)]
226    enum FakeError {
227        /// The remote branch moved away from the lease.
228        #[error("the remote branch moved away from the lease")]
229        StaleLease,
230        /// The fetch failed.
231        #[error("the fetch failed")]
232        Fetch,
233    }
234
235    /// Records every call; heads are scripted per scenario.
236    #[derive(Default)]
237    struct FakeGit {
238        /// The remote branch moved since it was observed.
239        stale_lease: bool,
240        /// Fetching the proposal branch fails for a reason other than absence.
241        fetch_fails: bool,
242        branch_head: Option<&'static str>,
243        /// The new proposal matches the existing branch head.
244        equivalent: bool,
245        /// Commits reachable from the authority tip.
246        merged: &'static [&'static str],
247        commit: Option<&'static str>,
248        head: RefCell<String>,
249        events: RefCell<Vec<String>>,
250    }
251
252    impl FakeGit {
253        fn record(&self, event: String) {
254            self.events.borrow_mut().push(event);
255        }
256
257        fn events(&self) -> Vec<String> {
258            self.events.borrow().clone()
259        }
260    }
261
262    impl GitRefs for FakeGit {
263        type Error = FakeError;
264
265        fn reference(&self, _: &Path, _: &str) -> Result<Option<String>, Self::Error> {
266            Ok(self.branch_head.map(str::to_owned))
267        }
268    }
269
270    impl GitHistory for FakeGit {
271        type Error = FakeError;
272
273        fn tags(&self, _: &Path, _: &str) -> Result<Vec<String>, Self::Error> {
274            Ok(Vec::new())
275        }
276
277        fn resolve(&self, _: &Path, rev: &str) -> Result<String, Self::Error> {
278            Ok(if rev == "HEAD" {
279                self.head.borrow().clone()
280            } else {
281                AUTHORITY.to_owned()
282            })
283        }
284
285        fn has_changes(
286            &self,
287            _: &Path,
288            _: Option<&str>,
289            _: &str,
290            _: UnitScope<'_>,
291        ) -> Result<bool, Self::Error> {
292            Ok(false)
293        }
294
295        fn commit_messages(
296            &self,
297            _: &Path,
298            _: Option<&str>,
299            _: &str,
300            _: UnitScope<'_>,
301        ) -> Result<Vec<String>, Self::Error> {
302            Ok(Vec::new())
303        }
304
305        fn equivalent(
306            &self,
307            _: &Path,
308            _: &str,
309            first: &str,
310            second: &str,
311        ) -> Result<bool, Self::Error> {
312            Ok(self.equivalent || first == second)
313        }
314
315        fn is_ancestor(&self, _: &Path, ancestor: &str, _: &str) -> Result<bool, Self::Error> {
316            Ok(self.merged.contains(&ancestor))
317        }
318    }
319
320    impl ProposalGit for FakeGit {
321        type Error = FakeError;
322
323        fn fetch(&self, _: &Path, refspec: &str) -> Result<(), Self::Error> {
324            self.record(format!("fetch {refspec}"));
325            Ok(())
326        }
327
328        fn fetch_if_present(&self, _: &Path, refspec: &str) -> Result<(), Self::Error> {
329            self.record(format!("fetch {refspec}"));
330            if self.fetch_fails {
331                return Err(FakeError::Fetch);
332            }
333            Ok(())
334        }
335
336        fn worktree_add(&self, _: &Path, _: &Path, baseline: &str) -> Result<(), Self::Error> {
337            self.record(format!("worktree from {baseline}"));
338            baseline.clone_into(&mut self.head.borrow_mut());
339            Ok(())
340        }
341
342        fn worktree_remove(&self, _: &Path, _: &Path) -> Result<(), Self::Error> {
343            self.record("remove".to_owned());
344            Ok(())
345        }
346
347        fn stage_update(&self, _: &Path, paths: &[PathBuf]) -> Result<(), Self::Error> {
348            self.record(format!("stage {paths:?}"));
349            Ok(())
350        }
351
352        fn commit_if_staged(
353            &self,
354            _: &Path,
355            message: &str,
356            _: Author<'_>,
357        ) -> Result<Option<String>, Self::Error> {
358            let Some(commit) = self.commit else {
359                return Ok(None);
360            };
361            self.record(format!("commit {message}"));
362            commit.clone_into(&mut self.head.borrow_mut());
363            Ok(Some(commit.to_owned()))
364        }
365
366        fn push_with_lease(
367            &self,
368            _: &Path,
369            branch: &str,
370            expected: Option<&str>,
371        ) -> Result<(), Self::Error> {
372            self.record(format!("push {branch} lease {expected:?}"));
373            if self.stale_lease {
374                return Err(FakeError::StaleLease);
375            }
376            Ok(())
377        }
378    }
379
380    fn request(mode: SyncMode<'_>) -> BranchSync<'_> {
381        BranchSync {
382            repository: Path::new("/repository"),
383            authority: &MAIN,
384            branch: "ephemeris/release",
385            author: AUTHOR,
386            mode,
387        }
388    }
389
390    fn commits(
391        paths: &[&str],
392    ) -> impl FnOnce(&Path, &Committer<'_, FakeGit>) -> Result<(), ProposalError> {
393        let paths: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
394        move |_, committer| {
395            committer
396                .commit(&paths, "release: update proposal")
397                .map(drop)
398        }
399    }
400
401    #[test]
402    fn manual_edits_on_a_stale_head_are_refused_without_a_push() {
403        let git = FakeGit {
404            branch_head: Some("edited"),
405            commit: Some("proposal"),
406            ..FakeGit::default()
407        };
408        let result = sync(
409            &git,
410            &request(SyncMode::Automatic {
411                expected: Expectation::Marked("generated"),
412            }),
413            commits(&["VERSION"]),
414        );
415        assert!(matches!(
416            result,
417            Err(ProposalError::ManualEdits { expected, actual: Some(actual) })
418                if expected == "generated" && actual == "edited"
419        ));
420        assert!(!git.events().iter().any(|event| event.starts_with("push ")));
421        assert_eq!(git.events().last().map(String::as_str), Some("remove"));
422    }
423
424    #[test]
425    fn a_branch_already_at_the_generated_proposal_is_kept_despite_a_stale_marker() {
426        let git = FakeGit {
427            branch_head: Some("pushed"),
428            equivalent: true,
429            commit: Some("regenerated"),
430            ..FakeGit::default()
431        };
432        let result = sync(
433            &git,
434            &request(SyncMode::Automatic {
435                expected: Expectation::NoPullRequest,
436            }),
437            commits(&["VERSION"]),
438        )
439        .unwrap();
440        assert_eq!(result.proposal_head, "pushed");
441        assert!(!git.events().iter().any(|event| event.starts_with("push ")));
442    }
443
444    #[test]
445    fn an_expected_head_requires_the_branch_to_exist() {
446        let git = FakeGit::default();
447        let result = sync(
448            &git,
449            &request(SyncMode::Automatic {
450                expected: Expectation::Marked("generated"),
451            }),
452            commits(&[]),
453        );
454        assert!(matches!(
455            result,
456            Err(ProposalError::ManualEdits {
457                actual: None,
458                expected: _
459            })
460        ));
461    }
462
463    #[test]
464    fn an_unreadable_marker_refuses_an_existing_branch_without_a_push() {
465        let git = FakeGit {
466            branch_head: Some("existing"),
467            commit: Some("proposal"),
468            ..FakeGit::default()
469        };
470        let result = sync(
471            &git,
472            &request(SyncMode::Automatic {
473                expected: Expectation::Unreadable(MarkerError::Invalid),
474            }),
475            commits(&["VERSION"]),
476        );
477        assert!(matches!(
478            result,
479            Err(ProposalError::UnreadableMarker { head, reason: MarkerError::Invalid })
480                if head == "existing"
481        ));
482        assert!(!git.events().iter().any(|event| event.starts_with("push ")));
483    }
484
485    #[test]
486    fn an_unreadable_marker_allows_a_first_proposal() {
487        let git = FakeGit {
488            commit: Some("proposal"),
489            ..FakeGit::default()
490        };
491        let result = sync(
492            &git,
493            &request(SyncMode::Automatic {
494                expected: Expectation::Unreadable(MarkerError::Missing),
495            }),
496            commits(&["VERSION"]),
497        )
498        .unwrap();
499        assert_eq!(result.proposal_head, "proposal");
500    }
501
502    #[test]
503    fn a_branch_without_a_pull_request_is_refused_without_a_push() {
504        let git = FakeGit {
505            branch_head: Some("existing"),
506            commit: Some("proposal"),
507            ..FakeGit::default()
508        };
509        let result = sync(
510            &git,
511            &request(SyncMode::Automatic {
512                expected: Expectation::NoPullRequest,
513            }),
514            commits(&["VERSION"]),
515        );
516        assert!(matches!(
517            result,
518            Err(ProposalError::UntrackedBranch { head }) if head == "existing"
519        ));
520        assert!(!git.events().iter().any(|event| event.starts_with("push ")));
521    }
522
523    #[test]
524    fn a_merged_branch_without_a_pull_request_is_replaced_under_a_lease() {
525        let git = FakeGit {
526            branch_head: Some("merged"),
527            merged: &["merged"],
528            commit: Some("proposal"),
529            ..FakeGit::default()
530        };
531        let result = sync(
532            &git,
533            &request(SyncMode::Automatic {
534                expected: Expectation::NoPullRequest,
535            }),
536            commits(&["VERSION"]),
537        )
538        .unwrap();
539        assert_eq!(result.proposal_head, "proposal");
540        assert!(
541            git.events()
542                .contains(&"push ephemeris/release lease Some(\"merged\")".to_owned())
543        );
544    }
545
546    #[test]
547    fn first_proposal_starts_from_the_authority_with_an_empty_lease() {
548        let git = FakeGit {
549            commit: Some("proposal"),
550            ..FakeGit::default()
551        };
552        let result = sync(
553            &git,
554            &request(SyncMode::Automatic {
555                expected: Expectation::NoPullRequest,
556            }),
557            commits(&["VERSION"]),
558        )
559        .unwrap();
560        assert_eq!(result.proposal_head, "proposal");
561        assert_eq!(
562            git.events(),
563            [
564                "fetch +refs/heads/main:refs/remotes/origin/main",
565                "fetch +refs/heads/ephemeris/release:refs/remotes/origin/ephemeris/release",
566                "worktree from a000000000000000000000000000000000000000",
567                "stage [\"VERSION\"]",
568                "commit release: update proposal",
569                "push ephemeris/release lease None",
570                "remove",
571            ]
572        );
573    }
574
575    #[test]
576    fn automatic_refresh_recreates_the_proposal_on_the_authority_and_leases_its_observed_head() {
577        let git = FakeGit {
578            branch_head: Some("generated"),
579            commit: Some("recreated"),
580            ..FakeGit::default()
581        };
582        let result = sync(
583            &git,
584            &request(SyncMode::Automatic {
585                expected: Expectation::Marked("generated"),
586            }),
587            commits(&["VERSION"]),
588        )
589        .unwrap();
590        assert_eq!(result.proposal_head, "recreated");
591        let events = git.events();
592        assert!(events.contains(&format!("worktree from {AUTHORITY}")));
593        assert!(events.contains(&"push ephemeris/release lease Some(\"generated\")".to_owned()));
594    }
595
596    #[test]
597    fn a_failed_branch_fetch_is_reported_before_any_worktree_exists() {
598        let git = FakeGit {
599            fetch_fails: true,
600            branch_head: Some("stale"),
601            ..FakeGit::default()
602        };
603        let result = sync(
604            &git,
605            &request(SyncMode::Automatic {
606                expected: Expectation::Marked("stale"),
607            }),
608            commits(&["VERSION"]),
609        );
610        assert!(matches!(result, Err(ProposalError::Git(_))));
611        assert!(
612            !git.events()
613                .iter()
614                .any(|event| event.starts_with("worktree "))
615        );
616    }
617
618    #[test]
619    fn an_equivalent_proposal_keeps_the_branch_head_and_is_not_pushed() {
620        let git = FakeGit {
621            branch_head: Some("generated"),
622            equivalent: true,
623            commit: Some("recreated"),
624            ..FakeGit::default()
625        };
626        let result = sync(
627            &git,
628            &request(SyncMode::Automatic {
629                expected: Expectation::Marked("generated"),
630            }),
631            commits(&["VERSION"]),
632        )
633        .unwrap();
634        assert_eq!(result.proposal_head, "generated");
635        assert!(!git.events().iter().any(|event| event.starts_with("push ")));
636    }
637
638    #[test]
639    fn a_proposal_without_edits_resets_the_branch_to_the_authority() {
640        let git = FakeGit {
641            branch_head: Some("generated"),
642            ..FakeGit::default()
643        };
644        let result = sync(
645            &git,
646            &request(SyncMode::Automatic {
647                expected: Expectation::Marked("generated"),
648            }),
649            commits(&[]),
650        )
651        .unwrap();
652        assert_eq!(result.proposal_head, AUTHORITY);
653        assert!(
654            git.events()
655                .contains(&"push ephemeris/release lease Some(\"generated\")".to_owned())
656        );
657    }
658
659    #[test]
660    fn a_first_proposal_without_edits_is_not_pushed() {
661        let git = FakeGit::default();
662        let result = sync(
663            &git,
664            &request(SyncMode::Automatic {
665                expected: Expectation::NoPullRequest,
666            }),
667            commits(&[]),
668        )
669        .unwrap();
670        assert_eq!(result.proposal_head, AUTHORITY);
671        assert!(!git.events().iter().any(|event| event.starts_with("push ")));
672    }
673
674    #[test]
675    fn regeneration_discards_branch_edits_and_leases_the_edited_head() {
676        let git = FakeGit {
677            branch_head: Some("edited"),
678            commit: Some("regenerated"),
679            ..FakeGit::default()
680        };
681        let result = sync(&git, &request(SyncMode::Regenerate), commits(&["VERSION"])).unwrap();
682        assert_eq!(result.proposal_head, "regenerated");
683        let events = git.events();
684        assert!(events.contains(&format!("worktree from {AUTHORITY}")));
685        assert!(events.contains(&"push ephemeris/release lease Some(\"edited\")".to_owned()));
686    }
687
688    #[test]
689    fn failed_edit_is_not_pushed_and_the_worktree_is_removed() {
690        let git = FakeGit {
691            commit: Some("proposal"),
692            ..FakeGit::default()
693        };
694        let result = sync(
695            &git,
696            &request(SyncMode::Automatic {
697                expected: Expectation::NoPullRequest,
698            }),
699            |_, _| Err(ProposalError::InvalidRequest),
700        );
701        assert!(matches!(result, Err(ProposalError::InvalidRequest)));
702        let events = git.events();
703        assert!(!events.iter().any(|event| event.starts_with("push ")));
704        assert_eq!(events.last().map(String::as_str), Some("remove"));
705    }
706
707    #[test]
708    fn a_stale_lease_fails_the_sync_with_the_git_error() {
709        let git = FakeGit {
710            stale_lease: true,
711            branch_head: Some("generated"),
712            commit: Some("proposal"),
713            ..FakeGit::default()
714        };
715        let result = sync(
716            &git,
717            &request(SyncMode::Automatic {
718                expected: Expectation::Marked("generated"),
719            }),
720            commits(&["VERSION"]),
721        );
722        assert!(matches!(
723            result,
724            Err(ProposalError::Git(error))
725                if error.downcast_ref::<FakeError>() == Some(&FakeError::StaleLease)
726        ));
727        assert_eq!(git.events().last().map(String::as_str), Some("remove"));
728    }
729}