Skip to content
741 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 /// Report the statuses g1t sets on a commit (workflow runs, deploys)
341 /// to the GitHub commit, while g1t runs workflows for the mirror: CI
342 /// failover, a takeover, or CI kept warm. GitHub links only.
343 pub report_checks: bool,
344 /// For followers.
345 pub remote_pushes: RemotePushes,
346}
347
348impl Default for MirrorSettings {
349 fn default() -> Self {
350 MirrorSettings {
351 notify: Notify::Banner,
352 take_over_after: None,
353 hand_back: HandBack::WhenClean,
354 keep_ci_warm: false,
355 github_workflows: true,
356 hold_deploys: true,
357 report_checks: true,
358 remote_pushes: RemotePushes::Adopt,
359 }
360 }
361}
362
363/// The shortest and longest wait a person may set before an automatic
364/// takeover.
365pub const TAKE_OVER_AFTER_MINUTES: (u32, u32) = (5, 24 * 60);
366
367/// A repository's link to a remote.
368#[derive(Clone, Debug, Serialize, Deserialize)]
369#[serde(rename_all = "camelCase")]
370pub struct Remote {
371 pub id: String,
372 pub repo_id: String,
373 /// `workspace/name` on g1t.
374 pub repo: String,
375 pub provider: RemoteProvider,
376 pub role: RemoteRole,
377 /// For people: `github.com/acme/web`.
378 pub name: String,
379 /// Its web address.
380 pub url: String,
381 pub state: RemoteState,
382 pub state_since: String,
383 /// Who put it in this state: a username, or `g1t` when it was automatic.
384 pub state_by: Option<String>,
385 /// Whether its host answers. A remote that refuses g1t's credential
386 /// still answers: that is [`Remote::last_error`].
387 pub reachable: bool,
388 pub unreachable_since: Option<String>,
389 pub synced_at: Option<String>,
390 pub last_error: Option<String>,
391 /// Why g1t could not report checks to the remote, until it next can:
392 /// the GitHub App lacks the Commit statuses permission, say.
393 #[serde(default)]
394 pub checks_problem: Option<String>,
395 pub settings: MirrorSettings,
396 pub created_at: String,
397}
398
399/// What happens to one ref when a takeover is handed back.
400#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case")]
402pub enum RefAction {
403 /// Nothing moved on either side, or both moved to the same commit.
404 Same,
405 /// Only g1t moved: pushed to the remote.
406 Push,
407 /// Only the remote moved: copied in.
408 Fetch,
409 /// g1t moved, but the remote protects the branch: sent as a pull
410 /// request from `g1t/handback/<branch>`, and g1t follows the remote.
411 PullRequest,
412 /// Both moved, apart. Waits for a [`RefDecision`].
413 Diverged,
414}
415
416/// A person's decision for a diverged ref.
417#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "snake_case")]
419pub enum RefDecision {
420 /// g1t's commit is pushed over the remote's.
421 KeepOurs,
422 /// The remote's commit is taken; g1t's is kept under `refs/g1t/replaced/`.
423 KeepTheirs,
424 /// g1t's commits go to the remote as a pull request.
425 PullRequest,
426}
427
428#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "camelCase")]
430pub struct RefPlan {
431 #[serde(rename = "ref")]
432 pub git_ref: String,
433 /// The commit both sides agreed on when the takeover began.
434 pub base: Option<String>,
435 pub ours: Option<String>,
436 pub theirs: Option<String>,
437 pub action: RefAction,
438 /// For a diverged ref, what someone decided.
439 pub decision: Option<RefDecision>,
440}
441
442/// What handing a takeover back would do, ref by ref.
443#[derive(Clone, Debug, Default, Serialize, Deserialize)]
444#[serde(rename_all = "camelCase")]
445pub struct HandbackPlan {
446 pub refs: Vec<RefPlan>,
447 /// Whether the remote answered, so the plan reflects it.
448 pub reachable: bool,
449 /// Whether it can go back now: the remote answers and every diverged
450 /// ref has a decision.
451 pub ready: bool,
452}
453
454/// Decides one ref of a hand-back. `base` is what both sides agreed on when
455/// the takeover began. A ref only g1t moved goes back by push, or by pull
456/// request when the remote protects it.
457pub fn ref_action(base: Option<&str>, ours: Option<&str>, theirs: Option<&str>, protected: bool) -> RefAction {
458 if ours == theirs {
459 return RefAction::Same;
460 }
461 let ours_moved = ours != base;
462 let theirs_moved = theirs != base;
463 match (ours_moved, theirs_moved) {
464 (false, _) => RefAction::Fetch,
465 (true, false) if protected && ours.is_some() && theirs.is_some() => RefAction::PullRequest,
466 (true, false) => RefAction::Push,
467 (true, true) => RefAction::Diverged,
468 }
469}
470
471impl HandbackPlan {
472 pub fn new(refs: Vec<RefPlan>, reachable: bool) -> Self {
473 let ready = reachable
474 && refs
475 .iter()
476 .all(|plan| plan.action != RefAction::Diverged || plan.decision.is_some());
477 HandbackPlan { refs, reachable, ready }
478 }
479
480 /// Whether it needs nobody: no ref is diverged.
481 pub fn clean(&self) -> bool {
482 self.reachable && !self.refs.iter().any(|plan| plan.action == RefAction::Diverged)
483 }
484}
485
486/// Everything about a repository's links, for its pages and API.
487#[derive(Clone, Debug, Default, Serialize, Deserialize)]
488#[serde(rename_all = "camelCase")]
489pub struct MirrorView {
490 pub remotes: Vec<Remote>,
491 /// While a takeover is on or being handed back.
492 pub plan: Option<HandbackPlan>,
493 /// Whether the viewer may change the links and take over.
494 pub can_manage: bool,
495 /// What the last action did that people should know: pull requests it
496 /// opened, branches it could not bring up to date.
497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
498 pub notes: Vec<String>,
499}
500
501/// A repository's links in brief, for lists.
502#[derive(Clone, Debug, Serialize, Deserialize)]
503#[serde(rename_all = "camelCase")]
504pub struct RemoteBrief {
505 pub repo_id: String,
506 pub role: RemoteRole,
507 pub name: String,
508 pub state: RemoteState,
509 pub reachable: bool,
510 /// When g1t last copied from it or pushed to it.
511 #[serde(default)]
512 pub synced_at: Option<String>,
513}
514
515/// The payload of the `mirror.*` events: `mirror.unreachable` (a mirror's
516/// remote stopped answering), `mirror.reachable` (it answers again),
517/// `mirror.state_changed` (stood by, CI failover, taken over, handing back,
518/// handed back) and `mirror.moved_in` (moved to g1t for good).
519#[derive(Clone, Debug, Default, Serialize, Deserialize)]
520#[serde(rename_all = "camelCase")]
521pub struct MirrorEvent {
522 pub repo_id: String,
523 /// `workspace/name`.
524 pub repo: String,
525 pub remote_id: String,
526 /// `github.com/acme/web`.
527 pub remote: String,
528 /// A `RemoteState` as text; absent once moved in.
529 #[serde(skip_serializing_if = "Option::is_none")]
530 pub state: Option<String>,
531 #[serde(skip_serializing_if = "Option::is_none")]
532 pub from: Option<String>,
533 /// Who did it: a username, or `g1t` when it happened on its own.
534 #[serde(skip_serializing_if = "Option::is_none")]
535 pub by: Option<String>,
536 /// What happened, for people.
537 pub title: String,
538 #[serde(skip_serializing_if = "Option::is_none")]
539 pub detail: Option<String>,
540 /// Usernames to tell in their inbox (the workspace's owners, when the
541 /// link's settings ask for it).
542 #[serde(default, skip_serializing_if = "Vec::is_empty")]
543 pub notify: Vec<String>,
544 /// The repository's mirroring settings page.
545 pub link: String,
546}
547
548// --- Methods of the integrations service ----------------------------------
549
550/// `mirror_view`: a repository's links, as the viewer may see them.
551#[derive(Debug, Serialize, Deserialize)]
552#[serde(rename_all = "camelCase")]
553pub struct MirrorViewArgs {
554 pub viewer: Option<User>,
555 pub repo_id: String,
556}
557
558/// `mirror_briefs`: the links of these repositories. Returns `[RemoteBrief]`.
559#[derive(Debug, Serialize, Deserialize)]
560#[serde(rename_all = "camelCase")]
561pub struct MirrorBriefsArgs {
562 pub repo_ids: Vec<String>,
563}
564
565/// `mirror_take_over`, `mirror_hand_back_plan`, `mirror_sync`: one
566/// repository's mirror. Return `Outcome<MirrorView>`.
567#[derive(Debug, Serialize, Deserialize)]
568#[serde(rename_all = "camelCase")]
569pub struct MirrorActArgs {
570 pub actor: User,
571 pub repo_id: String,
572}
573
574/// `mirror_ci`: starts or ends CI failover. Returns `Outcome<MirrorView>`.
575#[derive(Debug, Serialize, Deserialize)]
576#[serde(rename_all = "camelCase")]
577pub struct MirrorCiArgs {
578 pub actor: User,
579 pub repo_id: String,
580 pub on: bool,
581}
582
583/// `mirror_hand_back`: sends a takeover back, with decisions for diverged
584/// refs. Refused while a diverged ref has none. Returns
585/// `Outcome<MirrorView>`.
586#[derive(Debug, Serialize, Deserialize)]
587#[serde(rename_all = "camelCase")]
588pub struct MirrorHandBackArgs {
589 pub actor: User,
590 pub repo_id: String,
591 #[serde(default)]
592 pub decisions: BTreeMap<String, RefDecision>,
593}
594
595/// `mirror_settings`: changes a link's levers. Returns `Outcome<Remote>`.
596#[derive(Debug, Serialize, Deserialize)]
597#[serde(rename_all = "camelCase")]
598pub struct MirrorSettingsArgs {
599 pub actor: User,
600 pub remote_id: String,
601 pub settings: MirrorSettings,
602}
603
604/// `mirror_add`: links a repository to a remote on another g1t or any git
605/// host. GitHub links are made by `github_import`. A leader can only be
606/// added to an empty repository, which is then filled from it. Returns
607/// `Outcome<Remote>`.
608#[derive(Debug, Serialize, Deserialize)]
609#[serde(rename_all = "camelCase")]
610pub struct MirrorAddArgs {
611 pub actor: User,
612 pub repo_id: String,
613 pub provider: RemoteProvider,
614 pub role: RemoteRole,
615 /// The remote's https clone address.
616 pub url: String,
617 #[serde(default)]
618 pub username: Option<String>,
619 /// A token for it. Kept sealed; never shown again.
620 #[serde(default)]
621 pub token: Option<String>,
622}
623
624/// `mirror_move_in`: moves a mirror to g1t for good. The repository stops
625/// being a mirror and g1t stops tracking the remote: pushes made there no
626/// longer come here. Allowed while it stands by, in CI failover, or during
627/// a takeover (what g1t holds is kept as it is, nothing is handed back).
628/// With `keep_remote_updated`, the remote becomes a follower instead of
629/// being unlinked: g1t pushes to it from then on. Returns
630/// `Outcome<MirrorView>`.
631#[derive(Debug, Serialize, Deserialize)]
632#[serde(rename_all = "camelCase")]
633pub struct MirrorMoveInArgs {
634 pub actor: User,
635 pub repo_id: String,
636 #[serde(default)]
637 pub keep_remote_updated: bool,
638}
639
640/// `mirror_remove`: unlinks a remote. A mirror becomes an ordinary
641/// repository with what it has. Refused during a takeover. Returns
642/// `Outcome<bool>`.
643#[derive(Debug, Serialize, Deserialize)]
644#[serde(rename_all = "camelCase")]
645pub struct MirrorRemoveArgs {
646 pub actor: User,
647 pub remote_id: String,
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 #[test]
655 fn hand_back_decides_each_ref_from_the_base() {
656 let (a, b, c) = (Some("a"), Some("b"), Some("c"));
657 assert_eq!(ref_action(a, a, a, false), RefAction::Same);
658 assert_eq!(ref_action(a, b, b, false), RefAction::Same, "both moved to the same commit");
659 assert_eq!(ref_action(a, b, a, false), RefAction::Push);
660 assert_eq!(ref_action(a, b, a, true), RefAction::PullRequest, "a protected branch goes as a pull request");
661 assert_eq!(ref_action(a, a, b, false), RefAction::Fetch);
662 assert_eq!(ref_action(a, b, c, false), RefAction::Diverged);
663 assert_eq!(ref_action(None, b, None, true), RefAction::Push, "a new branch is pushed");
664 assert_eq!(ref_action(a, None, a, false), RefAction::Push, "a deleted branch is deleted there");
665 assert_eq!(ref_action(None, None, b, false), RefAction::Fetch, "a branch made there is copied in");
666 }
667
668 #[test]
669 fn a_plan_is_ready_once_every_diverged_ref_is_decided() {
670 let diverged = |decision| RefPlan {
671 git_ref: "refs/heads/docs".into(),
672 base: Some("a".into()),
673 ours: Some("b".into()),
674 theirs: Some("c".into()),
675 action: RefAction::Diverged,
676 decision,
677 };
678 let plan = HandbackPlan::new(vec![diverged(None)], true);
679 assert!(!plan.ready && !plan.clean());
680 let plan = HandbackPlan::new(vec![diverged(Some(RefDecision::KeepTheirs))], true);
681 assert!(plan.ready && !plan.clean());
682 assert!(!HandbackPlan::new(Vec::new(), false).ready, "not while the remote is away");
683 assert!(HandbackPlan::new(Vec::new(), true).clean());
684 }
685
686 #[test]
687 fn only_a_takeover_takes_writes() {
688 let mut mirror = RepoMirror { remote: "github.com/acme/web".into(), ..RepoMirror::default() };
689 for (state, writable) in [
690 (MirrorState::Standby, false),
691 (MirrorState::Ci, false),
692 (MirrorState::Takeover, true),
693 (MirrorState::HandingBack, false),
694 ] {
695 mirror.state = state;
696 assert_eq!(mirror.writable(), writable, "{state:?}");
697 assert_eq!(MirrorState::parse(state.as_str()), Some(state));
698 assert_eq!(RemoteState::parse(state.as_str()).mirror(), Some(state));
699 }
700 assert!(mirror_message("acme", "web", &RepoMirror { remote: "github.com/acme/web".into(), ..RepoMirror::default() })
701 .contains("is a mirror of github.com/acme/web"));
702 }
703
704 #[test]
705 fn a_mirror_offers_a_read_only_copy_until_it_is_taken_over() {
706 let mirror = |state| RepoMirror { state, remote: "github.com/acme/web".into(), ..RepoMirror::default() };
707 let read = [RepoArea::Code, RepoArea::Security, RepoArea::Insights, RepoArea::Settings];
708 let work = [RepoArea::Issues, RepoArea::Pulls, RepoArea::Agents, RepoArea::Deployments, RepoArea::Updates];
709 for state in [MirrorState::Standby, MirrorState::Ci, MirrorState::HandingBack] {
710 for area in read {
711 assert!(mirror(state).offers(area), "{state:?} {area:?}");
712 }
713 for area in work {
714 assert!(!mirror(state).offers(area), "{state:?} {area:?}");
715 }
716 }
717 for area in RepoArea::ALL {
718 assert!(mirror(MirrorState::Takeover).offers(area), "{area:?}");
719 assert!(offers(None, area), "a repository that leads offers {area:?}");
720 }
721 // Workflows: CI failover and takeovers, and a standby kept warm.
722 assert!(!mirror(MirrorState::Standby).offers(RepoArea::Workflows));
723 assert!(RepoMirror { warm: true, ..mirror(MirrorState::Standby) }.offers(RepoArea::Workflows));
724 assert!(mirror(MirrorState::Ci).offers(RepoArea::Workflows));
725 assert!(!mirror(MirrorState::HandingBack).offers(RepoArea::Workflows));
726 assert!(!RepoMirror { warm: true, ..mirror(MirrorState::HandingBack) }.offers(RepoArea::Workflows));
727 let message = updates_wait_message("acme", "web", &mirror(MirrorState::Standby));
728 assert!(message.starts_with("Security updates wait until acme/web is taken over or moved to g1t."));
729 assert!(message.contains("github.com/acme/web"));
730 }
731
732 #[test]
733 fn settings_default_to_doing_nothing_on_their_own() {
734 let settings: MirrorSettings = serde_json::from_str("{}").unwrap();
735 assert_eq!(settings, MirrorSettings::default());
736 assert_eq!(settings.notify, Notify::Banner);
737 assert_eq!(settings.take_over_after, None);
738 assert!(!settings.keep_ci_warm);
739 assert!(settings.report_checks, "checks are reported whenever g1t runs workflows, unless turned off");
740 }
741}