Skip to content
628 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 /// Report the statuses g1t sets on a commit (workflow runs, deploys)
256 /// to the GitHub commit, while g1t runs workflows for the mirror: CI
257 /// failover, a takeover, or CI kept warm. GitHub links only.
258 pub report_checks: bool,
259 /// For followers.
260 pub remote_pushes: RemotePushes,
261}
262
263impl Default for MirrorSettings {
264 fn default() -> Self {
265 MirrorSettings {
266 notify: Notify::Banner,
267 take_over_after: None,
268 hand_back: HandBack::WhenClean,
269 keep_ci_warm: false,
270 github_workflows: true,
271 hold_deploys: true,
272 report_checks: true,
273 remote_pushes: RemotePushes::Adopt,
274 }
275 }
276}
277
278/// The shortest and longest wait a person may set before an automatic
279/// takeover.
280pub const TAKE_OVER_AFTER_MINUTES: (u32, u32) = (5, 24 * 60);
281
282/// A repository's link to a remote.
283#[derive(Clone, Debug, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct Remote {
286 pub id: String,
287 pub repo_id: String,
288 /// `workspace/name` on g1t.
289 pub repo: String,
290 pub provider: RemoteProvider,
291 pub role: RemoteRole,
292 /// For people: `github.com/acme/web`.
293 pub name: String,
294 /// Its web address.
295 pub url: String,
296 pub state: RemoteState,
297 pub state_since: String,
298 /// Who put it in this state: a username, or `g1t` when it was automatic.
299 pub state_by: Option<String>,
300 /// Whether its host answers. A remote that refuses g1t's credential
301 /// still answers: that is [`Remote::last_error`].
302 pub reachable: bool,
303 pub unreachable_since: Option<String>,
304 pub synced_at: Option<String>,
305 pub last_error: Option<String>,
306 /// Why g1t could not report checks to the remote, until it next can:
307 /// the GitHub App lacks the Commit statuses permission, say.
308 #[serde(default)]
309 pub checks_problem: Option<String>,
310 pub settings: MirrorSettings,
311 pub created_at: String,
312}
313
314/// What happens to one ref when a takeover is handed back.
315#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "snake_case")]
317pub enum RefAction {
318 /// Nothing moved on either side, or both moved to the same commit.
319 Same,
320 /// Only g1t moved: pushed to the remote.
321 Push,
322 /// Only the remote moved: copied in.
323 Fetch,
324 /// g1t moved, but the remote protects the branch: sent as a pull
325 /// request from `g1t/handback/<branch>`, and g1t follows the remote.
326 PullRequest,
327 /// Both moved, apart. Waits for a [`RefDecision`].
328 Diverged,
329}
330
331/// A person's decision for a diverged ref.
332#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(rename_all = "snake_case")]
334pub enum RefDecision {
335 /// g1t's commit is pushed over the remote's.
336 KeepOurs,
337 /// The remote's commit is taken; g1t's is kept under `refs/g1t/replaced/`.
338 KeepTheirs,
339 /// g1t's commits go to the remote as a pull request.
340 PullRequest,
341}
342
343#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct RefPlan {
346 #[serde(rename = "ref")]
347 pub git_ref: String,
348 /// The commit both sides agreed on when the takeover began.
349 pub base: Option<String>,
350 pub ours: Option<String>,
351 pub theirs: Option<String>,
352 pub action: RefAction,
353 /// For a diverged ref, what someone decided.
354 pub decision: Option<RefDecision>,
355}
356
357/// What handing a takeover back would do, ref by ref.
358#[derive(Clone, Debug, Default, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct HandbackPlan {
361 pub refs: Vec<RefPlan>,
362 /// Whether the remote answered, so the plan reflects it.
363 pub reachable: bool,
364 /// Whether it can go back now: the remote answers and every diverged
365 /// ref has a decision.
366 pub ready: bool,
367}
368
369/// Decides one ref of a hand-back. `base` is what both sides agreed on when
370/// the takeover began. A ref only g1t moved goes back by push, or by pull
371/// request when the remote protects it.
372pub fn ref_action(base: Option<&str>, ours: Option<&str>, theirs: Option<&str>, protected: bool) -> RefAction {
373 if ours == theirs {
374 return RefAction::Same;
375 }
376 let ours_moved = ours != base;
377 let theirs_moved = theirs != base;
378 match (ours_moved, theirs_moved) {
379 (false, _) => RefAction::Fetch,
380 (true, false) if protected && ours.is_some() && theirs.is_some() => RefAction::PullRequest,
381 (true, false) => RefAction::Push,
382 (true, true) => RefAction::Diverged,
383 }
384}
385
386impl HandbackPlan {
387 pub fn new(refs: Vec<RefPlan>, reachable: bool) -> Self {
388 let ready = reachable
389 && refs
390 .iter()
391 .all(|plan| plan.action != RefAction::Diverged || plan.decision.is_some());
392 HandbackPlan { refs, reachable, ready }
393 }
394
395 /// Whether it needs nobody: no ref is diverged.
396 pub fn clean(&self) -> bool {
397 self.reachable && !self.refs.iter().any(|plan| plan.action == RefAction::Diverged)
398 }
399}
400
401/// Everything about a repository's links, for its pages and API.
402#[derive(Clone, Debug, Default, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404pub struct MirrorView {
405 pub remotes: Vec<Remote>,
406 /// While a takeover is on or being handed back.
407 pub plan: Option<HandbackPlan>,
408 /// Whether the viewer may change the links and take over.
409 pub can_manage: bool,
410 /// What the last action did that people should know: pull requests it
411 /// opened, branches it could not bring up to date.
412 #[serde(default, skip_serializing_if = "Vec::is_empty")]
413 pub notes: Vec<String>,
414}
415
416/// A repository's links in brief, for lists.
417#[derive(Clone, Debug, Serialize, Deserialize)]
418#[serde(rename_all = "camelCase")]
419pub struct RemoteBrief {
420 pub repo_id: String,
421 pub role: RemoteRole,
422 pub name: String,
423 pub state: RemoteState,
424 pub reachable: bool,
425 /// When g1t last copied from it or pushed to it.
426 #[serde(default)]
427 pub synced_at: Option<String>,
428}
429
430/// The payload of the `mirror.*` events: `mirror.unreachable` (a mirror's
431/// remote stopped answering), `mirror.reachable` (it answers again),
432/// `mirror.state_changed` (stood by, CI failover, taken over, handing back,
433/// handed back) and `mirror.moved_in` (moved to g1t for good).
434#[derive(Clone, Debug, Default, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct MirrorEvent {
437 pub repo_id: String,
438 /// `workspace/name`.
439 pub repo: String,
440 pub remote_id: String,
441 /// `github.com/acme/web`.
442 pub remote: String,
443 /// A `RemoteState` as text; absent once moved in.
444 #[serde(skip_serializing_if = "Option::is_none")]
445 pub state: Option<String>,
446 #[serde(skip_serializing_if = "Option::is_none")]
447 pub from: Option<String>,
448 /// Who did it: a username, or `g1t` when it happened on its own.
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub by: Option<String>,
451 /// What happened, for people.
452 pub title: String,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub detail: Option<String>,
455 /// Usernames to tell in their inbox (the workspace's owners, when the
456 /// link's settings ask for it).
457 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub notify: Vec<String>,
459 /// The repository's mirroring settings page.
460 pub link: String,
461}
462
463// --- Methods of the integrations service ----------------------------------
464
465/// `mirror_view`: a repository's links, as the viewer may see them.
466#[derive(Debug, Serialize, Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct MirrorViewArgs {
469 pub viewer: Option<User>,
470 pub repo_id: String,
471}
472
473/// `mirror_briefs`: the links of these repositories. Returns `[RemoteBrief]`.
474#[derive(Debug, Serialize, Deserialize)]
475#[serde(rename_all = "camelCase")]
476pub struct MirrorBriefsArgs {
477 pub repo_ids: Vec<String>,
478}
479
480/// `mirror_take_over`, `mirror_hand_back_plan`, `mirror_sync`: one
481/// repository's mirror. Return `Outcome<MirrorView>`.
482#[derive(Debug, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484pub struct MirrorActArgs {
485 pub actor: User,
486 pub repo_id: String,
487}
488
489/// `mirror_ci`: starts or ends CI failover. Returns `Outcome<MirrorView>`.
490#[derive(Debug, Serialize, Deserialize)]
491#[serde(rename_all = "camelCase")]
492pub struct MirrorCiArgs {
493 pub actor: User,
494 pub repo_id: String,
495 pub on: bool,
496}
497
498/// `mirror_hand_back`: sends a takeover back, with decisions for diverged
499/// refs. Refused while a diverged ref has none. Returns
500/// `Outcome<MirrorView>`.
501#[derive(Debug, Serialize, Deserialize)]
502#[serde(rename_all = "camelCase")]
503pub struct MirrorHandBackArgs {
504 pub actor: User,
505 pub repo_id: String,
506 #[serde(default)]
507 pub decisions: BTreeMap<String, RefDecision>,
508}
509
510/// `mirror_settings`: changes a link's levers. Returns `Outcome<Remote>`.
511#[derive(Debug, Serialize, Deserialize)]
512#[serde(rename_all = "camelCase")]
513pub struct MirrorSettingsArgs {
514 pub actor: User,
515 pub remote_id: String,
516 pub settings: MirrorSettings,
517}
518
519/// `mirror_add`: links a repository to a remote on another g1t or any git
520/// host. GitHub links are made by `github_import`. A leader can only be
521/// added to an empty repository, which is then filled from it. Returns
522/// `Outcome<Remote>`.
523#[derive(Debug, Serialize, Deserialize)]
524#[serde(rename_all = "camelCase")]
525pub struct MirrorAddArgs {
526 pub actor: User,
527 pub repo_id: String,
528 pub provider: RemoteProvider,
529 pub role: RemoteRole,
530 /// The remote's https clone address.
531 pub url: String,
532 #[serde(default)]
533 pub username: Option<String>,
534 /// A token for it. Kept sealed; never shown again.
535 #[serde(default)]
536 pub token: Option<String>,
537}
538
539/// `mirror_move_in`: moves a mirror to g1t for good. The repository stops
540/// being a mirror and g1t stops tracking the remote: pushes made there no
541/// longer come here. Allowed while it stands by, in CI failover, or during
542/// a takeover (what g1t holds is kept as it is, nothing is handed back).
543/// With `keep_remote_updated`, the remote becomes a follower instead of
544/// being unlinked: g1t pushes to it from then on. Returns
545/// `Outcome<MirrorView>`.
546#[derive(Debug, Serialize, Deserialize)]
547#[serde(rename_all = "camelCase")]
548pub struct MirrorMoveInArgs {
549 pub actor: User,
550 pub repo_id: String,
551 #[serde(default)]
552 pub keep_remote_updated: bool,
553}
554
555/// `mirror_remove`: unlinks a remote. A mirror becomes an ordinary
556/// repository with what it has. Refused during a takeover. Returns
557/// `Outcome<bool>`.
558#[derive(Debug, Serialize, Deserialize)]
559#[serde(rename_all = "camelCase")]
560pub struct MirrorRemoveArgs {
561 pub actor: User,
562 pub remote_id: String,
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn hand_back_decides_each_ref_from_the_base() {
571 let (a, b, c) = (Some("a"), Some("b"), Some("c"));
572 assert_eq!(ref_action(a, a, a, false), RefAction::Same);
573 assert_eq!(ref_action(a, b, b, false), RefAction::Same, "both moved to the same commit");
574 assert_eq!(ref_action(a, b, a, false), RefAction::Push);
575 assert_eq!(ref_action(a, b, a, true), RefAction::PullRequest, "a protected branch goes as a pull request");
576 assert_eq!(ref_action(a, a, b, false), RefAction::Fetch);
577 assert_eq!(ref_action(a, b, c, false), RefAction::Diverged);
578 assert_eq!(ref_action(None, b, None, true), RefAction::Push, "a new branch is pushed");
579 assert_eq!(ref_action(a, None, a, false), RefAction::Push, "a deleted branch is deleted there");
580 assert_eq!(ref_action(None, None, b, false), RefAction::Fetch, "a branch made there is copied in");
581 }
582
583 #[test]
584 fn a_plan_is_ready_once_every_diverged_ref_is_decided() {
585 let diverged = |decision| RefPlan {
586 git_ref: "refs/heads/docs".into(),
587 base: Some("a".into()),
588 ours: Some("b".into()),
589 theirs: Some("c".into()),
590 action: RefAction::Diverged,
591 decision,
592 };
593 let plan = HandbackPlan::new(vec![diverged(None)], true);
594 assert!(!plan.ready && !plan.clean());
595 let plan = HandbackPlan::new(vec![diverged(Some(RefDecision::KeepTheirs))], true);
596 assert!(plan.ready && !plan.clean());
597 assert!(!HandbackPlan::new(Vec::new(), false).ready, "not while the remote is away");
598 assert!(HandbackPlan::new(Vec::new(), true).clean());
599 }
600
601 #[test]
602 fn only_a_takeover_takes_writes() {
603 let mut mirror = RepoMirror { remote: "github.com/acme/web".into(), ..RepoMirror::default() };
604 for (state, writable) in [
605 (MirrorState::Standby, false),
606 (MirrorState::Ci, false),
607 (MirrorState::Takeover, true),
608 (MirrorState::HandingBack, false),
609 ] {
610 mirror.state = state;
611 assert_eq!(mirror.writable(), writable, "{state:?}");
612 assert_eq!(MirrorState::parse(state.as_str()), Some(state));
613 assert_eq!(RemoteState::parse(state.as_str()).mirror(), Some(state));
614 }
615 assert!(mirror_message("acme", "web", &RepoMirror { remote: "github.com/acme/web".into(), ..RepoMirror::default() })
616 .contains("is a mirror of github.com/acme/web"));
617 }
618
619 #[test]
620 fn settings_default_to_doing_nothing_on_their_own() {
621 let settings: MirrorSettings = serde_json::from_str("{}").unwrap();
622 assert_eq!(settings, MirrorSettings::default());
623 assert_eq!(settings.notify, Notify::Banner);
624 assert_eq!(settings.take_over_after, None);
625 assert!(!settings.keep_ci_warm);
626 assert!(settings.report_checks, "checks are reported whenever g1t runs workflows, unless turned off");
627 }
628}