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