Skip to content
731 linesCodeBlameRaw
1//! Mirroring: a repository kept in step with copies of it on other hosts.
2//!
3//! Every linked repository has exactly one leader, where work happens; the
4//! others follow it. A **mirror** follows a remote that leads (GitHub,
5//! another g1t, any git host). While it stands by it is an exact, read-only
6//! copy that runs nothing. Someone can **take over**: g1t leads for a
7//! while, then **hands back**, sending what was done to the remote. A
8//! repository g1t leads can be **mirrored to** any number of followers.
9//!
10//! The integrations service keeps the links (`remotes`) and decides; the
11//! repos service keeps each repository's [`RepoMirror`], so pushes and
12//! merges are refused or allowed without asking anyone.
13//!
14//! What a mirror offers on g1t is one rule, [`RepoMirror::offers`], by
15//! state (`mirrorOffers` in the TypeScript contracts):
16//!
17//! | Area | Standing by | CI failover | Taken over | Handing back |
18//! | --- | --- | --- | --- | --- |
19//! | `Code`: files, commits, branches, tags, compare, releases (read) | ✓ | ✓ | ✓ | ✓ |
20//! | `Security`: what scanning found, read-only | ✓ | ✓ | ✓ | ✓ |
21//! | `Insights`, `Settings` (mirroring settings included) | ✓ | ✓ | ✓ | ✓ |
22//! | `Workflows`: runs on g1t | with Keep CI warm | ✓ | ✓ | – |
23//! | `Issues`, `Pulls`, `Agents`, `Deployments` | – | – | ✓ | – |
24//! | `Updates`: security and dependency updates, upkeep, anything that opens a branch or a pull request on its own | – | – | ✓ | – |
25//!
26//! The sidebar lists only what is offered, a page not offered says so in
27//! place of its list or form, every service refuses the writes, and g1t's
28//! own automation checks the same rule before it opens anything.
29
30use std::collections::BTreeMap;
31
32use serde::{Deserialize, Serialize};
33
34use crate::User;
35
36/// Where a mirror stands with the remote it follows.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum MirrorState {
40 /// A quiet copy: it follows every push and runs nothing.
41 #[default]
42 Standby,
43 /// The remote keeps the code; g1t runs its workflows (CI failover).
44 Ci,
45 /// g1t leads for now: pushes, pull requests, agents and workflows work.
46 Takeover,
47 /// Sending what was done during a takeover back to the remote. The
48 /// repository is read-only until it is done.
49 HandingBack,
50}
51
52impl MirrorState {
53 pub fn as_str(self) -> &'static str {
54 match self {
55 MirrorState::Standby => "standby",
56 MirrorState::Ci => "ci",
57 MirrorState::Takeover => "takeover",
58 MirrorState::HandingBack => "handing_back",
59 }
60 }
61
62 pub fn parse(text: &str) -> Option<MirrorState> {
63 Some(match text {
64 "standby" => MirrorState::Standby,
65 "ci" => MirrorState::Ci,
66 "takeover" => MirrorState::Takeover,
67 "handing_back" => MirrorState::HandingBack,
68 _ => return None,
69 })
70 }
71
72 /// Whether g1t leads, so the repository takes writes.
73 pub fn leads(self) -> bool {
74 self == MirrorState::Takeover
75 }
76
77 /// Whether g1t copies the remote's pushes in.
78 pub fn follows(self) -> bool {
79 matches!(self, MirrorState::Standby | MirrorState::Ci)
80 }
81}
82
83/// A repository's tie to the remote it mirrors, as the repos service keeps
84/// it on [`crate::repos::Repo`]. Absent for a repository that leads.
85#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct RepoMirror {
88 pub state: MirrorState,
89 /// The remote, for people: `github.com/acme/web`.
90 pub remote: String,
91 /// Its web address.
92 pub url: String,
93 /// RFC 3339: when it entered this state.
94 pub since: String,
95 /// Run `.g1t/workflows` on pushes copied in while it stands by.
96 #[serde(default)]
97 pub warm: bool,
98 /// Run `.github/workflows` as well, in CI failover and during a takeover.
99 #[serde(default)]
100 pub github_workflows: bool,
101 /// Hold jobs that name an `environment:` for approval, in CI failover
102 /// and during a takeover, so nothing deploys twice.
103 #[serde(default)]
104 pub hold_deploys: bool,
105}
106
107impl RepoMirror {
108 /// Whether the repository takes pushes, merges, issues and agents.
109 pub fn writable(&self) -> bool {
110 self.state.leads()
111 }
112
113 /// Whether the repository offers `area` now: the table in this
114 /// module's comment. A mirror g1t has taken over offers everything;
115 /// one standing by or handing back is a read-only copy (code, what
116 /// security scanning found, insights, settings). Workflows run in CI
117 /// failover, and while standing by only when the link keeps CI warm.
118 pub fn offers(&self, area: RepoArea) -> bool {
119 if self.writable() || area.read_only() {
120 return true;
121 }
122 match area {
123 RepoArea::Workflows => self.state == MirrorState::Ci || (self.state == MirrorState::Standby && self.warm),
124 _ => false,
125 }
126 }
127}
128
129/// The parts of a repository a mirror may or may not offer: the sidebar's
130/// pages, and `Updates` for what g1t does on its own (security and
131/// dependency updates, upkeep, anything that opens a branch or a pull
132/// request without being asked).
133#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum RepoArea {
136 Code,
137 Issues,
138 Pulls,
139 Agents,
140 Workflows,
141 Deployments,
142 Security,
143 Insights,
144 Settings,
145 Updates,
146}
147
148impl RepoArea {
149 pub const ALL: [RepoArea; 10] = [
150 RepoArea::Code,
151 RepoArea::Issues,
152 RepoArea::Pulls,
153 RepoArea::Agents,
154 RepoArea::Workflows,
155 RepoArea::Deployments,
156 RepoArea::Security,
157 RepoArea::Insights,
158 RepoArea::Settings,
159 RepoArea::Updates,
160 ];
161
162 /// Whether every mirror offers it, whatever its state: an exact copy, read.
163 pub fn read_only(self) -> bool {
164 matches!(self, RepoArea::Code | RepoArea::Security | RepoArea::Insights | RepoArea::Settings)
165 }
166}
167
168/// Whether a repository with `mirror` offers `area`: everything when it
169/// leads, else [`RepoMirror::offers`].
170pub fn offers(mirror: Option<&RepoMirror>, area: RepoArea) -> bool {
171 mirror.is_none_or(|mirror| mirror.offers(area))
172}
173
174/// Why g1t's own updates wait on a mirror: the sentence the security pages
175/// and the service share (`mirrorUpdatesWait` in the TypeScript contracts).
176pub fn updates_wait_message(namespace: &str, name: &str, mirror: &RepoMirror) -> String {
177 format!(
178 "Security updates wait until {namespace}/{name} is taken over or moved to g1t. It is a mirror of {remote}, so nothing opens a branch or a pull request here on its own.",
179 remote = mirror.remote
180 )
181}
182
183/// Why a mirror refuses a write, for people and for `git push`.
184pub fn mirror_message(namespace: &str, name: &str, mirror: &RepoMirror) -> String {
185 match mirror.state {
186 MirrorState::HandingBack => format!(
187 "{namespace}/{name} is handing back to {}. It takes changes again once that is done.",
188 mirror.remote
189 ),
190 _ => format!(
191 "{namespace}/{name} is a mirror of {remote}, so it is read-only here. Push to {remote}, or take over in Settings → Mirroring to work on g1t.",
192 remote = mirror.remote
193 ),
194 }
195}
196
197/// The kinds of host a remote can be. Each is an adapter in integrations
198/// (`src/remotes.rs`); a new host is one more arm.
199#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum RemoteProvider {
202 /// Through g1t's GitHub App.
203 Github,
204 /// Another g1t: g1t.sh, or one run by its owner.
205 G1t,
206 /// Any git host over HTTPS, with a username and token.
207 Git,
208}
209
210impl RemoteProvider {
211 pub fn as_str(self) -> &'static str {
212 match self {
213 RemoteProvider::Github => "github",
214 RemoteProvider::G1t => "g1t",
215 RemoteProvider::Git => "git",
216 }
217 }
218
219 pub fn parse(text: &str) -> Option<RemoteProvider> {
220 Some(match text {
221 "github" => RemoteProvider::Github,
222 "g1t" => RemoteProvider::G1t,
223 "git" => RemoteProvider::Git,
224 _ => return None,
225 })
226 }
227}
228
229/// Which side leads.
230#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum RemoteRole {
233 /// The remote leads; the repository on g1t is its mirror.
234 Leader,
235 /// g1t leads; the remote is kept in step with it.
236 Follower,
237}
238
239impl RemoteRole {
240 pub fn as_str(self) -> &'static str {
241 match self {
242 RemoteRole::Leader => "leader",
243 RemoteRole::Follower => "follower",
244 }
245 }
246}
247
248/// A link's state. A leader is `standby`, `ci`, `takeover` or
249/// `handing_back` (see [`MirrorState`]); a follower is `following`, or
250/// `stuck` when the remote refused what g1t sent.
251#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "snake_case")]
253pub enum RemoteState {
254 Standby,
255 Ci,
256 Takeover,
257 HandingBack,
258 Following,
259 Stuck,
260}
261
262impl RemoteState {
263 pub fn as_str(self) -> &'static str {
264 match self {
265 RemoteState::Standby => "standby",
266 RemoteState::Ci => "ci",
267 RemoteState::Takeover => "takeover",
268 RemoteState::HandingBack => "handing_back",
269 RemoteState::Following => "following",
270 RemoteState::Stuck => "stuck",
271 }
272 }
273
274 pub fn parse(text: &str) -> RemoteState {
275 match text {
276 "ci" => RemoteState::Ci,
277 "takeover" => RemoteState::Takeover,
278 "handing_back" => RemoteState::HandingBack,
279 "following" => RemoteState::Following,
280 "stuck" => RemoteState::Stuck,
281 _ => RemoteState::Standby,
282 }
283 }
284
285 /// The mirror state of a leader in this state.
286 pub fn mirror(self) -> Option<MirrorState> {
287 MirrorState::parse(self.as_str())
288 }
289}
290
291/// Who hears that a remote stopped answering. Nobody is woken up unless
292/// they asked to be.
293#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "snake_case")]
295pub enum Notify {
296 /// The repository shows it, and that is all.
297 #[default]
298 Banner,
299 /// Also an inbox item for the repository's admins.
300 Inbox,
301}
302
303/// When a takeover is handed back once the remote answers again.
304#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "snake_case")]
306pub enum HandBack {
307 /// Only when someone hands it back.
308 Ask,
309 /// On its own when every branch goes back without a decision.
310 #[default]
311 WhenClean,
312}
313
314/// What a follower does about pushes made on the remote itself.
315#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "snake_case")]
317pub enum RemotePushes {
318 /// Fast-forwards are taken in; anything else is shown as diverged.
319 #[default]
320 Adopt,
321 /// g1t's branches are pushed over them (what they pointed at is kept).
322 Overwrite,
323}
324
325/// The levers on a link. Every one defaults to doing nothing on its own.
326#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "camelCase", default)]
328pub struct MirrorSettings {
329 pub notify: Notify,
330 /// Take over on its own once the remote has not answered for this many
331 /// minutes. `None`: only when someone takes over.
332 pub take_over_after: Option<u32>,
333 pub hand_back: HandBack,
334 /// Run `.g1t/workflows` on pushes copied in while standing by.
335 pub keep_ci_warm: bool,
336 /// Run `.github/workflows` in CI failover and during a takeover.
337 pub github_workflows: bool,
338 /// Hold deploy jobs for approval in CI failover and during a takeover.
339 pub hold_deploys: bool,
340 /// For followers.
341 pub remote_pushes: RemotePushes,
342}
343
344impl Default for MirrorSettings {
345 fn default() -> Self {
346 MirrorSettings {
347 notify: Notify::Banner,
348 take_over_after: None,
349 hand_back: HandBack::WhenClean,
350 keep_ci_warm: false,
351 github_workflows: true,
352 hold_deploys: true,
353 remote_pushes: RemotePushes::Adopt,
354 }
355 }
356}
357
358/// The shortest and longest wait a person may set before an automatic
359/// takeover.
360pub const TAKE_OVER_AFTER_MINUTES: (u32, u32) = (5, 24 * 60);
361
362/// A repository's link to a remote.
363#[derive(Clone, Debug, Serialize, Deserialize)]
364#[serde(rename_all = "camelCase")]
365pub struct Remote {
366 pub id: String,
367 pub repo_id: String,
368 /// `workspace/name` on g1t.
369 pub repo: String,
370 pub provider: RemoteProvider,
371 pub role: RemoteRole,
372 /// For people: `github.com/acme/web`.
373 pub name: String,
374 /// Its web address.
375 pub url: String,
376 pub state: RemoteState,
377 pub state_since: String,
378 /// Who put it in this state: a username, or `g1t` when it was automatic.
379 pub state_by: Option<String>,
380 /// Whether its host answers. A remote that refuses g1t's credential
381 /// still answers: that is [`Remote::last_error`].
382 pub reachable: bool,
383 pub unreachable_since: Option<String>,
384 pub synced_at: Option<String>,
385 pub last_error: Option<String>,
386 pub settings: MirrorSettings,
387 pub created_at: String,
388}
389
390/// What happens to one ref when a takeover is handed back.
391#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum RefAction {
394 /// Nothing moved on either side, or both moved to the same commit.
395 Same,
396 /// Only g1t moved: pushed to the remote.
397 Push,
398 /// Only the remote moved: copied in.
399 Fetch,
400 /// g1t moved, but the remote protects the branch: sent as a pull
401 /// request from `g1t/handback/<branch>`, and g1t follows the remote.
402 PullRequest,
403 /// Both moved, apart. Waits for a [`RefDecision`].
404 Diverged,
405}
406
407/// A person's decision for a diverged ref.
408#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(rename_all = "snake_case")]
410pub enum RefDecision {
411 /// g1t's commit is pushed over the remote's.
412 KeepOurs,
413 /// The remote's commit is taken; g1t's is kept under `refs/g1t/replaced/`.
414 KeepTheirs,
415 /// g1t's commits go to the remote as a pull request.
416 PullRequest,
417}
418
419#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct RefPlan {
422 #[serde(rename = "ref")]
423 pub git_ref: String,
424 /// The commit both sides agreed on when the takeover began.
425 pub base: Option<String>,
426 pub ours: Option<String>,
427 pub theirs: Option<String>,
428 pub action: RefAction,
429 /// For a diverged ref, what someone decided.
430 pub decision: Option<RefDecision>,
431}
432
433/// What handing a takeover back would do, ref by ref.
434#[derive(Clone, Debug, Default, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct HandbackPlan {
437 pub refs: Vec<RefPlan>,
438 /// Whether the remote answered, so the plan reflects it.
439 pub reachable: bool,
440 /// Whether it can go back now: the remote answers and every diverged
441 /// ref has a decision.
442 pub ready: bool,
443}
444
445/// Decides one ref of a hand-back. `base` is what both sides agreed on when
446/// the takeover began. A ref only g1t moved goes back by push, or by pull
447/// request when the remote protects it.
448pub fn ref_action(base: Option<&str>, ours: Option<&str>, theirs: Option<&str>, protected: bool) -> RefAction {
449 if ours == theirs {
450 return RefAction::Same;
451 }
452 let ours_moved = ours != base;
453 let theirs_moved = theirs != base;
454 match (ours_moved, theirs_moved) {
455 (false, _) => RefAction::Fetch,
456 (true, false) if protected && ours.is_some() && theirs.is_some() => RefAction::PullRequest,
457 (true, false) => RefAction::Push,
458 (true, true) => RefAction::Diverged,
459 }
460}
461
462impl HandbackPlan {
463 pub fn new(refs: Vec<RefPlan>, reachable: bool) -> Self {
464 let ready = reachable
465 && refs
466 .iter()
467 .all(|plan| plan.action != RefAction::Diverged || plan.decision.is_some());
468 HandbackPlan { refs, reachable, ready }
469 }
470
471 /// Whether it needs nobody: no ref is diverged.
472 pub fn clean(&self) -> bool {
473 self.reachable && !self.refs.iter().any(|plan| plan.action == RefAction::Diverged)
474 }
475}
476
477/// Everything about a repository's links, for its pages and API.
478#[derive(Clone, Debug, Default, Serialize, Deserialize)]
479#[serde(rename_all = "camelCase")]
480pub struct MirrorView {
481 pub remotes: Vec<Remote>,
482 /// While a takeover is on or being handed back.
483 pub plan: Option<HandbackPlan>,
484 /// Whether the viewer may change the links and take over.
485 pub can_manage: bool,
486 /// What the last action did that people should know: pull requests it
487 /// opened, branches it could not bring up to date.
488 #[serde(default, skip_serializing_if = "Vec::is_empty")]
489 pub notes: Vec<String>,
490}
491
492/// A repository's links in brief, for lists.
493#[derive(Clone, Debug, Serialize, Deserialize)]
494#[serde(rename_all = "camelCase")]
495pub struct RemoteBrief {
496 pub repo_id: String,
497 pub role: RemoteRole,
498 pub name: String,
499 pub state: RemoteState,
500 pub reachable: bool,
501 /// When g1t last copied from it or pushed to it.
502 #[serde(default)]
503 pub synced_at: Option<String>,
504}
505
506/// The payload of the `mirror.*` events: `mirror.unreachable` (a mirror's
507/// remote stopped answering), `mirror.reachable` (it answers again),
508/// `mirror.state_changed` (stood by, CI failover, taken over, handing back,
509/// handed back) and `mirror.moved_in` (moved to g1t for good).
510#[derive(Clone, Debug, Default, Serialize, Deserialize)]
511#[serde(rename_all = "camelCase")]
512pub struct MirrorEvent {
513 pub repo_id: String,
514 /// `workspace/name`.
515 pub repo: String,
516 pub remote_id: String,
517 /// `github.com/acme/web`.
518 pub remote: String,
519 /// A `RemoteState` as text; absent once moved in.
520 #[serde(skip_serializing_if = "Option::is_none")]
521 pub state: Option<String>,
522 #[serde(skip_serializing_if = "Option::is_none")]
523 pub from: Option<String>,
524 /// Who did it: a username, or `g1t` when it happened on its own.
525 #[serde(skip_serializing_if = "Option::is_none")]
526 pub by: Option<String>,
527 /// What happened, for people.
528 pub title: String,
529 #[serde(skip_serializing_if = "Option::is_none")]
530 pub detail: Option<String>,
531 /// Usernames to tell in their inbox (the workspace's owners, when the
532 /// link's settings ask for it).
533 #[serde(default, skip_serializing_if = "Vec::is_empty")]
534 pub notify: Vec<String>,
535 /// The repository's mirroring settings page.
536 pub link: String,
537}
538
539// --- Methods of the integrations service ----------------------------------
540
541/// `mirror_view`: a repository's links, as the viewer may see them.
542#[derive(Debug, Serialize, Deserialize)]
543#[serde(rename_all = "camelCase")]
544pub struct MirrorViewArgs {
545 pub viewer: Option<User>,
546 pub repo_id: String,
547}
548
549/// `mirror_briefs`: the links of these repositories. Returns `[RemoteBrief]`.
550#[derive(Debug, Serialize, Deserialize)]
551#[serde(rename_all = "camelCase")]
552pub struct MirrorBriefsArgs {
553 pub repo_ids: Vec<String>,
554}
555
556/// `mirror_take_over`, `mirror_hand_back_plan`, `mirror_sync`: one
557/// repository's mirror. Return `Outcome<MirrorView>`.
558#[derive(Debug, Serialize, Deserialize)]
559#[serde(rename_all = "camelCase")]
560pub struct MirrorActArgs {
561 pub actor: User,
562 pub repo_id: String,
563}
564
565/// `mirror_ci`: starts or ends CI failover. Returns `Outcome<MirrorView>`.
566#[derive(Debug, Serialize, Deserialize)]
567#[serde(rename_all = "camelCase")]
568pub struct MirrorCiArgs {
569 pub actor: User,
570 pub repo_id: String,
571 pub on: bool,
572}
573
574/// `mirror_hand_back`: sends a takeover back, with decisions for diverged
575/// refs. Refused while a diverged ref has none. Returns
576/// `Outcome<MirrorView>`.
577#[derive(Debug, Serialize, Deserialize)]
578#[serde(rename_all = "camelCase")]
579pub struct MirrorHandBackArgs {
580 pub actor: User,
581 pub repo_id: String,
582 #[serde(default)]
583 pub decisions: BTreeMap<String, RefDecision>,
584}
585
586/// `mirror_settings`: changes a link's levers. Returns `Outcome<Remote>`.
587#[derive(Debug, Serialize, Deserialize)]
588#[serde(rename_all = "camelCase")]
589pub struct MirrorSettingsArgs {
590 pub actor: User,
591 pub remote_id: String,
592 pub settings: MirrorSettings,
593}
594
595/// `mirror_add`: links a repository to a remote on another g1t or any git
596/// host. GitHub links are made by `github_import`. A leader can only be
597/// added to an empty repository, which is then filled from it. Returns
598/// `Outcome<Remote>`.
599#[derive(Debug, Serialize, Deserialize)]
600#[serde(rename_all = "camelCase")]
601pub struct MirrorAddArgs {
602 pub actor: User,
603 pub repo_id: String,
604 pub provider: RemoteProvider,
605 pub role: RemoteRole,
606 /// The remote's https clone address.
607 pub url: String,
608 #[serde(default)]
609 pub username: Option<String>,
610 /// A token for it. Kept sealed; never shown again.
611 #[serde(default)]
612 pub token: Option<String>,
613}
614
615/// `mirror_move_in`: moves a mirror to g1t for good. The repository stops
616/// being a mirror and g1t stops tracking the remote: pushes made there no
617/// longer come here. Allowed while it stands by, in CI failover, or during
618/// a takeover (what g1t holds is kept as it is, nothing is handed back).
619/// With `keep_remote_updated`, the remote becomes a follower instead of
620/// being unlinked: g1t pushes to it from then on. Returns
621/// `Outcome<MirrorView>`.
622#[derive(Debug, Serialize, Deserialize)]
623#[serde(rename_all = "camelCase")]
624pub struct MirrorMoveInArgs {
625 pub actor: User,
626 pub repo_id: String,
627 #[serde(default)]
628 pub keep_remote_updated: bool,
629}
630
631/// `mirror_remove`: unlinks a remote. A mirror becomes an ordinary
632/// repository with what it has. Refused during a takeover. Returns
633/// `Outcome<bool>`.
634#[derive(Debug, Serialize, Deserialize)]
635#[serde(rename_all = "camelCase")]
636pub struct MirrorRemoveArgs {
637 pub actor: User,
638 pub remote_id: String,
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn hand_back_decides_each_ref_from_the_base() {
647 let (a, b, c) = (Some("a"), Some("b"), Some("c"));
648 assert_eq!(ref_action(a, a, a, false), RefAction::Same);
649 assert_eq!(ref_action(a, b, b, false), RefAction::Same, "both moved to the same commit");
650 assert_eq!(ref_action(a, b, a, false), RefAction::Push);
651 assert_eq!(ref_action(a, b, a, true), RefAction::PullRequest, "a protected branch goes as a pull request");
652 assert_eq!(ref_action(a, a, b, false), RefAction::Fetch);
653 assert_eq!(ref_action(a, b, c, false), RefAction::Diverged);
654 assert_eq!(ref_action(None, b, None, true), RefAction::Push, "a new branch is pushed");
655 assert_eq!(ref_action(a, None, a, false), RefAction::Push, "a deleted branch is deleted there");
656 assert_eq!(ref_action(None, None, b, false), RefAction::Fetch, "a branch made there is copied in");
657 }
658
659 #[test]
660 fn a_plan_is_ready_once_every_diverged_ref_is_decided() {
661 let diverged = |decision| RefPlan {
662 git_ref: "refs/heads/docs".into(),
663 base: Some("a".into()),
664 ours: Some("b".into()),
665 theirs: Some("c".into()),
666 action: RefAction::Diverged,
667 decision,
668 };
669 let plan = HandbackPlan::new(vec![diverged(None)], true);
670 assert!(!plan.ready && !plan.clean());
671 let plan = HandbackPlan::new(vec![diverged(Some(RefDecision::KeepTheirs))], true);
672 assert!(plan.ready && !plan.clean());
673 assert!(!HandbackPlan::new(Vec::new(), false).ready, "not while the remote is away");
674 assert!(HandbackPlan::new(Vec::new(), true).clean());
675 }
676
677 #[test]
678 fn only_a_takeover_takes_writes() {
679 let mut mirror = RepoMirror { remote: "github.com/acme/web".into(), ..RepoMirror::default() };
680 for (state, writable) in [
681 (MirrorState::Standby, false),
682 (MirrorState::Ci, false),
683 (MirrorState::Takeover, true),
684 (MirrorState::HandingBack, false),
685 ] {
686 mirror.state = state;
687 assert_eq!(mirror.writable(), writable, "{state:?}");
688 assert_eq!(MirrorState::parse(state.as_str()), Some(state));
689 assert_eq!(RemoteState::parse(state.as_str()).mirror(), Some(state));
690 }
691 assert!(mirror_message("acme", "web", &RepoMirror { remote: "github.com/acme/web".into(), ..RepoMirror::default() })
692 .contains("is a mirror of github.com/acme/web"));
693 }
694
695 #[test]
696 fn a_mirror_offers_a_read_only_copy_until_it_is_taken_over() {
697 let mirror = |state| RepoMirror { state, remote: "github.com/acme/web".into(), ..RepoMirror::default() };
698 let read = [RepoArea::Code, RepoArea::Security, RepoArea::Insights, RepoArea::Settings];
699 let work = [RepoArea::Issues, RepoArea::Pulls, RepoArea::Agents, RepoArea::Deployments, RepoArea::Updates];
700 for state in [MirrorState::Standby, MirrorState::Ci, MirrorState::HandingBack] {
701 for area in read {
702 assert!(mirror(state).offers(area), "{state:?} {area:?}");
703 }
704 for area in work {
705 assert!(!mirror(state).offers(area), "{state:?} {area:?}");
706 }
707 }
708 for area in RepoArea::ALL {
709 assert!(mirror(MirrorState::Takeover).offers(area), "{area:?}");
710 assert!(offers(None, area), "a repository that leads offers {area:?}");
711 }
712 // Workflows: CI failover and takeovers, and a standby kept warm.
713 assert!(!mirror(MirrorState::Standby).offers(RepoArea::Workflows));
714 assert!(RepoMirror { warm: true, ..mirror(MirrorState::Standby) }.offers(RepoArea::Workflows));
715 assert!(mirror(MirrorState::Ci).offers(RepoArea::Workflows));
716 assert!(!mirror(MirrorState::HandingBack).offers(RepoArea::Workflows));
717 assert!(!RepoMirror { warm: true, ..mirror(MirrorState::HandingBack) }.offers(RepoArea::Workflows));
718 let message = updates_wait_message("acme", "web", &mirror(MirrorState::Standby));
719 assert!(message.starts_with("Security updates wait until acme/web is taken over or moved to g1t."));
720 assert!(message.contains("github.com/acme/web"));
721 }
722
723 #[test]
724 fn settings_default_to_doing_nothing_on_their_own() {
725 let settings: MirrorSettings = serde_json::from_str("{}").unwrap();
726 assert_eq!(settings, MirrorSettings::default());
727 assert_eq!(settings.notify, Notify::Banner);
728 assert_eq!(settings.take_over_after, None);
729 assert!(!settings.keep_ci_warm);
730 }
731}