1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub(crate) enum SyncMode<'a> {
16 Automatic { expected: Expectation<'a> },
18 Regenerate,
21}
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub(crate) enum Expectation<'a> {
26 Marked(&'a str),
28 NoPullRequest,
31 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#[derive(Debug)]
58pub(crate) struct SyncResult {
59 pub(crate) proposal_head: String,
61}
62
63#[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
73pub(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 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 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
114pub(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
164fn 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 #[derive(Debug, Eq, PartialEq, thiserror::Error)]
226 enum FakeError {
227 #[error("the remote branch moved away from the lease")]
229 StaleLease,
230 #[error("the fetch failed")]
232 Fetch,
233 }
234
235 #[derive(Default)]
237 struct FakeGit {
238 stale_lease: bool,
240 fetch_fails: bool,
242 branch_head: Option<&'static str>,
243 equivalent: bool,
245 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}