OXYGENHUB

Backups per repository: leave one out, or give it its own schedule d3b048b27277

docs/backups.md §5. A repository's Settings › Backup (and PUT
/repos/{owner}/{name}/backup, by its owners and admins; migration 0062):
with its owner's backup, left out, or at least hourly or daily.

Left out, it is in no new run. With its own schedule, its owner's backup
runs at least that often, and runs even when the owner's own schedule is
off, then taking only the repositories that asked. A run still sends only
what changed and keeps every included repository, so each run stays
whole and restores everything. A run asked for by hand takes every
repository not left out. Admin › Backups counts what a scheduled run
covers.

Tested: one left out is in no run; an hourly repository makes a daily
owner due; with the owner off, only it is backed up; by hand, all but the
one left out; the settings card saves and refuses an unknown choice.

closes #177
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 03:27 UTC
Signature
✔ signed by Andy McCall
Hash
d3b048b27277329d343d78bf508f6ac4c04766e8ef9ae64c13cdce55571a0397
Depends on
18e381ea88c2 19bb31ff1f5c 1e8a6e8bb91b 1f78655a0466 3f4b0ea6eabf 5ef6cf72cd19 6dbd999ad794 7edaff01d506 9008bf6c2b8c b64e2537d702 d0fbea2eb46e d23464c0ac60 d7d182467382 e71583501b1d eb2a3502601d f4d6ef418a96
Depended on by
9041db4e4d92 c7067c7bda56 cb7b3cd45dc0 0ee5c3fb32eb a9f1c0bf403a 4a68f1064b08 16ee5d19d220 8dcaeeb848c6 107eaba52c9d 5d90e0e045e2 2e9188ca4f98 7b9f4570daf5
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
27 atoms, 1 tree operation, 10 files

Browse the tree at this change

added crates/hub/migrations/0062_repo_backup.sql +4 −0
1 +
-- Per-repository backups (docs/backups.md §5, oxygenhub#177): empty goes
2 +
-- with its owner's backup; `off` leaves it out; `hourly` or `daily` backs
3 +
-- it up at least that often, whatever its owner's schedule.
4 +
ALTER TABLE repositories ADD COLUMN backup TEXT NOT NULL DEFAULT '';
modified crates/hub/src/api/mod.rs +27 −0
…
85 unchanged lines
86 86
        .route("/repos/{owner}/{name}/tags", get(tags))
87 87
        .route("/repos/{owner}/{name}/consumers", get(consumers))
88 88
        .route("/repos/{owner}/{name}/diffusion", get(diffusion).put(set_diffusion))
89 +
        .route("/repos/{owner}/{name}/backup", axum::routing::put(set_backup))
89 90
        .route("/repos/{owner}/{name}/history/{r}", get(history))
90 91
        .route("/repos/{owner}/{name}/changes/{hash}", get(change))
91 92
        .route("/repos/{owner}/{name}/tree/{r}", get(tree_root))
…
478 unchanged lines
570 571
}
571 572
572 573
#[derive(serde::Deserialize)]
574 +
struct BackupSetting {
575 +
    /// `owner` (with its owner's), `hourly`, `daily` or `off`.
576 +
    backup: String,
577 +
}
578 +
579 +
/// `PUT /repos/{owner}/{name}/backup {"backup": ...}`: this repository's
580 +
/// backup (docs/backups.md §5). Owners and admins.
581 +
async fn set_backup(
582 +
    State(app): State<Shared>,
583 +
    headers: HeaderMap,
584 +
    Path((owner, name)): Path<(String, String)>,
585 +
    Json(b): Json<BackupSetting>,
586 +
) -> Result<Json<serde_json::Value>, ApiError> {
587 +
    let c = caller(&app, &headers).await?;
588 +
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?;
589 +
    let setting = match b.backup.as_str() {
590 +
        "owner" => "",
591 +
        "hourly" | "daily" | "off" => b.backup.as_str(),
592 +
        _ => return Err(ApiError(StatusCode::BAD_REQUEST, "backup is owner, hourly, daily or off".into())),
593 +
    };
594 +
    db::repos::set_backup(&app.db, row.id, setting).await?;
595 +
    db::accounts::audit(&app.db, c.account.as_ref(), "repo.backup", &format!("{owner}/{name}"), &b.backup).await.ok();
596 +
    Ok(Json(serde_json::json!({ "backup": b.backup })))
597 +
}
598 +
599 +
#[derive(serde::Deserialize)]
573 600
struct DiffusionSetting {
574 601
    /// `off`, `green`, `any`, or `default` (the site's).
575 602
    follow: String,
…
1946 unchanged lines
modified crates/hub/src/backups.rs +42 −9
…
110 unchanged lines
111 111
    sqlx::query_as("SELECT * FROM backup_runs WHERE owner = ? ORDER BY id DESC LIMIT ?").bind(owner).bind(limit).fetch_all(pool).await
112 112
}
113 113
114 +
/// What a repository may choose about its backup (§5): with its owner's,
115 +
/// left out, or at least hourly or daily whatever its owner's schedule.
116 +
pub const REPO_CHOICES: &[(&str, &str)] =
117 +
    &[("", "With its owner's backup"), ("hourly", "At least every hour"), ("daily", "At least every day"), ("off", "Left out")];
118 +
119 +
/// Whether `row` is in its owner's run: never when it is left out, always
120 +
/// when it has its own schedule, else as its owner's schedule says (a run
121 +
/// asked for by hand takes every repository not left out).
122 +
pub fn included(s: &Setting, row: &db::repos::RepoRow, by_hand: bool) -> bool {
123 +
    match row.backup.as_str() {
124 +
        "off" => false,
125 +
        "hourly" | "daily" => true,
126 +
        _ => by_hand || s.every != "off",
127 +
    }
128 +
}
129 +
130 +
/// Seconds between an owner's runs: the shortest of its own schedule and
131 +
/// its repositories' own; none when nothing is scheduled.
132 +
pub fn period_with(s: &Setting, repos: &[db::repos::RepoRow]) -> Option<i64> {
133 +
    let own = repos.iter().filter_map(|r| match r.backup.as_str() {
134 +
        "hourly" => Some(3600),
135 +
        "daily" => Some(86_400),
136 +
        _ => None,
137 +
    });
138 +
    s.period_s().into_iter().chain(own).min()
139 +
}
140 +
114 141
/// One owner's backup as Admin › Backups shows it (§5): its setting, its
115 142
/// newest run, and how many runs failed in the last day. An administrator
116 143
/// sees that it ran, never what it wrote.
…
34 unchanged lines
151 178
                .bind(day_ago)
152 179
                .fetch_one(pool)
153 180
                .await?;
154 -
        let repos = counts.iter().find(|(o, _)| *o == s.owner).map_or(0, |c| c.1);
181 +
        let repos = db::repos::list(pool).await?.iter().filter(|r| r.owner == s.owner && included(&s, r, false)).count() as i64;
155 182
        out.push(Overview { setting: s, last, failed_day, repos });
156 183
    }
157 -
    let uncovered =
158 -
        counts.into_iter().filter(|(owner, _)| !out.iter().any(|o| o.setting.owner == *owner && o.setting.every != "off")).collect();
184 +
    let uncovered = counts.into_iter().filter(|(owner, _)| !out.iter().any(|o| o.setting.owner == *owner && o.repos > 0)).collect();
159 185
    Ok((out, uncovered))
160 186
}
161 187
…
782 unchanged lines
944 970
            .bind(trigger)
945 971
            .fetch_one(&app.db)
946 972
            .await?;
947 -
    let outcome = run_into(app, &s, &at).await;
973 +
    let outcome = run_into(app, &s, &at, trigger == "now").await;
948 974
    let (status, detail, sent) = match outcome {
949 975
        Ok((sent, problems)) if problems.is_empty() => ("ok", String::new(), sent),
950 976
        Ok((sent, problems)) => ("partial", problems.join("; "), sent),
…
17 unchanged lines
968 994
    Ok(r)
969 995
}
970 996
971 -
async fn run_into(app: &Shared, s: &Setting, at: &str) -> Result<((i64, Sent), Vec<String>)> {
997 +
async fn run_into(app: &Shared, s: &Setting, at: &str, by_hand: bool) -> Result<((i64, Sent), Vec<String>)> {
972 998
    let dest: std::sync::Arc<dyn Destination> = destination(app, s)?.into();
973 999
    let known = {
974 1000
        let d = dest.clone();
975 1001
        tokio::task::spawn_blocking(move || last_manifests(d.as_ref())).await?
976 1002
    };
977 -
    let mine: Vec<db::repos::RepoRow> = db::repos::list(&app.db).await?.into_iter().filter(|r| r.owner == s.owner).collect();
1003 +
    // Its repositories, but those left out (§5).
1004 +
    let mine: Vec<db::repos::RepoRow> =
1005 +
        db::repos::list(&app.db).await?.into_iter().filter(|r| r.owner == s.owner && included(s, r, by_hand)).collect();
978 1006
    let mut problems = Vec::new();
979 1007
    let mut sent = Sent::default();
980 1008
    let mut repos = 0i64;
…
216 unchanged lines
1197 1225
    }
1198 1226
}
1199 1227
1200 -
/// The owners whose next run is due now.
1228 +
/// The owners whose next run is due now: by their own schedule, or by
1229 +
/// the shortest schedule one of their repositories chose (§5). A run takes
1230 +
/// every included repository and sends only what changed, so each run
1231 +
/// stays whole and a restore point has everything.
1201 1232
pub async fn due(pool: &Pool) -> sqlx::Result<Vec<Setting>> {
1202 -
    let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE every != 'off'").fetch_all(pool).await?;
1233 +
    let all: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(pool).await?;
1234 +
    let repos = db::repos::list(pool).await?;
1203 1235
    let now = db::now_ms();
1204 1236
    let mut out = Vec::new();
1205 1237
    for s in all {
1206 -
        let Some(period) = s.period_s() else { continue };
1238 +
        let theirs: Vec<db::repos::RepoRow> = repos.iter().filter(|r| r.owner == s.owner).cloned().collect();
1239 +
        let Some(period) = period_with(&s, &theirs) else { continue };
1207 1240
        let last: Option<i64> =
1208 1241
            sqlx::query_scalar("SELECT MAX(started_ms) FROM backup_runs WHERE owner = ?").bind(&s.owner).fetch_one(pool).await?;
1209 1242
        if last.is_none_or(|l| now - l >= period * 1000) {
…
139 unchanged lines
modified crates/hub/src/db/repos.rs +9 −0
…
34 unchanged lines
35 35
    /// (migration 0061).
36 36
    #[sqlx(default)]
37 37
    pub diffusion_land: String,
38 +
    /// Backups (migration 0062): empty with its owner's, `off`, or its own
39 +
    /// `hourly` or `daily`.
40 +
    #[sqlx(default)]
41 +
    pub backup: String,
38 42
    /// Read-only, with why (migration 0040, #108).
39 43
    #[sqlx(default)]
40 44
    pub archived: bool,
…
262 unchanged lines
303 307
/// `on`, `off`, or `` (follow hub.toml's `[flow] repos`).
304 308
pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> {
305 309
    sqlx::query("UPDATE repositories SET flow = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?;
310 +
    Ok(())
311 +
}
312 +
313 +
pub async fn set_backup(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> {
314 +
    sqlx::query("UPDATE repositories SET backup = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?;
306 315
    Ok(())
307 316
}
308 317
…
34 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
279 unchanged lines
280 280
        .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror))
281 281
        .route("/{owner}/{name}/settings/flow", post(repo_settings::flow))
282 282
        .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion))
283 +
        .route("/{owner}/{name}/settings/backup", post(repo_settings::backup))
283 284
        .route("/{owner}/{name}/settings/storage", post(repo_settings::storage))
284 285
        .route("/{owner}/{name}/settings/coverage", post(repo_settings::coverage))
285 286
        .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token))
…
480 unchanged lines
modified crates/hub/src/web/repo_settings.rs +47 −0
…
191 unchanged lines
192 192
    storage: Option<crate::storage::Storage>,
193 193
    /// The coverage check's rules (#113).
194 194
    coverage: crate::coverage::Rules,
195 +
    /// Its owner's backup, when one is set up (docs/backups.md §5).
196 +
    backup_owner: Option<crate::backups::Setting>,
195 197
}
196 198
199 +
impl RepoSettingsPage {
200 +
    fn backup_choices(&self) -> &'static [(&'static str, &'static str)] {
201 +
        crate::backups::REPO_CHOICES
202 +
    }
203 +
}
204 +
197 205
async fn settings_page(
198 206
    app: &Shared,
199 207
    shell: Shell,
…
37 unchanged lines
237 245
    let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
238 246
    let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
239 247
    let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
248 +
    let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
240 249
    Ok(Page(RepoSettingsPage {
250 +
        backup_owner,
241 251
        coverage,
242 252
        storage,
243 253
        assistants,
…
492 unchanged lines
736 746
    csrf_token: String,
737 747
    #[serde(default)]
738 748
    enabled: Option<String>,
749 +
}
750 +
751 +
#[derive(Deserialize)]
752 +
pub struct BackupForm {
753 +
    #[serde(rename = "_csrf")]
754 +
    csrf_token: String,
755 +
    /// Empty (with its owner's), `hourly`, `daily` or `off`.
756 +
    #[serde(default)]
757 +
    backup: String,
758 +
}
759 +
760 +
/// This repository's backup (docs/backups.md §5): with its owner's, left
761 +
/// out, or at least hourly or daily. Its owners and admins decide.
762 +
pub async fn backup(
763 +
    State(app): State<Shared>,
764 +
    viewer: Viewer,
765 +
    Path((owner, name)): Path<(String, String)>,
766 +
    Form(f): Form<BackupForm>,
767 +
) -> Result<Page<RepoSettingsPage>, Error> {
768 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
769 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
770 +
    csrf(&viewer, &f.csrf_token, &shell)?;
771 +
    if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) {
772 +
        return settings_page(&app, shell, row, role, None, Some("Pick how this repository is backed up.".into())).await;
773 +
    }
774 +
    db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?;
775 +
    let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() };
776 +
    accounts::audit(&app.db, viewer.account(), "repo.backup", &format!("{owner}/{name}"), what).await.ok();
777 +
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
778 +
    let notice = match f.backup.as_str() {
779 +
        "off" => "This repository is left out of its owner's backups from the next run.".to_string(),
780 +
        "" => "This repository is backed up with its owner's.".to_string(),
781 +
        every => {
782 +
            format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" })
783 +
        }
784 +
    };
785 +
    settings_page(&app, shell, row, role, Some(notice), None).await
739 786
}
740 787
741 788
#[derive(Deserialize)]
…
233 unchanged lines
modified crates/hub/tests/backups.rs +53 −0
…
506 unchanged lines
507 507
    .unwrap();
508 508
    assert!(next.number > p.number, "{} after {}", next.number, p.number);
509 509
}
510 +
511 +
/// docs/backups.md §5 (BP3): a repository left out is in no run; one with
512 +
/// its own schedule makes its owner's backup run at least that often, and
513 +
/// alone when its owner's is off; a run by hand takes all but those left
514 +
/// out.
515 +
#[tokio::test(flavor = "multi_thread")]
516 +
async fn repositories_choose_their_own_backup() {
517 +
    let store = tempfile::tempdir().unwrap();
518 +
    let h = hub(store.path()).await;
519 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
520 +
    for n in ["game", "notes", "secret"] {
521 +
        create(&h.app, "andy", n, hub_api::Visibility::Public).await;
522 +
        let work = tempfile::tempdir().unwrap();
523 +
        push(&format!("{}/andy/{n}", h.base), work.path(), &[("README.md", n)]);
524 +
    }
525 +
    let id = |n: &'static str| {
526 +
        let db = h.app.db.clone();
527 +
        async move { db::repos::get(&db, "andy", n).await.unwrap().unwrap().id }
528 +
    };
529 +
    db::repos::set_backup(&h.app.db, id("secret").await, "off").await.unwrap();
530 +
    let mut s = Setting::off("andy", "user");
531 +
    s.every = "daily".into();
532 +
    s.target = store.path().display().to_string();
533 +
    backups::save(&h.app.db, &s).await.unwrap();
534 +
    let names = |at: String| {
535 +
        let dest = Directory { root: store.path().join("andy") };
536 +
        let mut v = dest.list(&format!("runs/{at}/records")).unwrap();
537 +
        v.sort();
538 +
        v
539 +
    };
540 +
    let first = backups::run(&h.app, "andy", "schedule").await.unwrap();
541 +
    assert_eq!((first.status.as_str(), first.repos), ("ok", 2), "{}", first.detail);
542 +
    assert_eq!(names(first.at.clone()), vec!["game.json", "notes.json"], "the one left out is not in it");
543 +
    // Daily, and it just ran: not due, until a repository asks for hourly.
544 +
    assert!(backups::due(&h.app.db).await.unwrap().is_empty());
545 +
    db::repos::set_backup(&h.app.db, id("game").await, "hourly").await.unwrap();
546 +
    sqlx::query("UPDATE backup_runs SET started_ms = started_ms - 2 * 3600 * 1000").execute(&h.app.db).await.unwrap();
547 +
    assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1, "hourly for the game");
548 +
    // The owner's schedule off: only the game, which asks for its own.
549 +
    s.every = "off".into();
550 +
    backups::save(&h.app.db, &s).await.unwrap();
551 +
    assert_eq!(backups::due(&h.app.db).await.unwrap().len(), 1);
552 +
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
553 +
    let second = backups::run(&h.app, "andy", "schedule").await.unwrap();
554 +
    assert_eq!(names(second.at.clone()), vec!["game.json"]);
555 +
    // By hand: everything but the one left out.
556 +
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
557 +
    let third = backups::run(&h.app, "andy", "now").await.unwrap();
558 +
    assert_eq!(names(third.at.clone()), vec!["game.json", "notes.json"]);
559 +
    // Admin › Backups counts what the next scheduled run covers.
560 +
    let (owners, uncovered) = backups::overview(&h.app.db).await.unwrap();
561 +
    assert_eq!((owners[0].repos, uncovered.len()), (1, 0));
562 +
}
modified crates/hub/tests/issues.rs +9 −0
…
1019 unchanged lines
1020 1020
    let missing = &admin.body[admin.body.find("Not backed up").unwrap()..];
1021 1021
    assert!(missing.contains("href=\"/bob\"") && missing.contains("1 repository"), "{missing}");
1022 1022
    assert!(get(app, "/admin", Some(&andy)).await.body.contains("/admin/backups"));
1023 +
    // A repository chooses its own (BP3): its owners decide.
1024 +
    let game = get(app, "/bob/game/settings", Some(&bob)).await;
1025 +
    assert!(game.body.contains("bob has no backup set up yet") && game.body.contains(r#"name="backup" value="" checked"#), "{}", game.body);
1026 +
    let csrf = csrf_of(&game.body);
1027 +
    let saved = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=off")).await;
1028 +
    assert!(saved.body.contains("left out of its owner"), "{}", saved.body);
1029 +
    assert_eq!(db::repos::get(&app.db, "bob", "game").await.unwrap().unwrap().backup, "off");
1030 +
    let bad = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=weekly")).await;
1031 +
    assert!(bad.body.contains("Pick how this repository is backed up."));
1023 1032
}
modified docs/backups.md +11 −4
…
5 unchanged lines
6 6
and wiki's store byte for byte, the records as JSON, retention, failure
7 7
notices, and restore (`oxygenhub admin backup run|restore`). Since
8 8
2026-10-14 (oxygenhub#175) a restore brings back the records too (§7) and
9 -
release assets travel with the stores; Admin › Backups (#176). Next:
10 -
per-repository settings, S3.
9 +
release assets travel with the stores; Admin › Backups (#176);
10 +
per-repository settings (#177). Next: S3.
11 11
12 12
## 1. Why
13 13
…
95 unchanged lines
109 109
  repositories no backup covers. They may not read an owner's bucket, only
110 110
  see that it ran. The site's own backup runs on its host, outside the
111 111
  hub, and the page says so.
112 -
- **A repository** (later): opt a repository out of its owner's backup,
113 -
  or give it its own schedule.
112 +
- **A repository** (built 2026-10-14, oxygenhub#177): its Settings ›
113 +
  Backup, or `PUT /repos/{owner}/{name}/backup {"backup": "owner" |
114 +
  "hourly" | "daily" | "off"}`, by its owners and admins (migration
115 +
  0062). Left out, it is in no new run. With its own schedule, its
116 +
  owner's backup runs at least that often, and runs even when the owner's
117 +
  own schedule is off (taking only the repositories that asked); a run
118 +
  sends only what changed and keeps every included repository, so each
119 +
  run stays whole and restores everything. A run asked for by hand takes
120 +
  every repository not left out.
114 121
115 122
The API mirrors the pages: `GET/PUT /api/v1/user/backups`,
116 123
`GET/PUT /api/v1/orgs/{name}/backups`, the run endpoint above, and
…
85 unchanged lines
modified templates/repo_settings.html +14 −0
…
101 unchanged lines
102 102
  <p class="small mute">A subrepo's <code>.oxymodules</code> entry overrides this with <code>follow = green</code>, <code>any</code> or <code>off</code>; <code>land = auto</code> or <code>propose</code> overrides the landing, <code>target</code> names the bookmark to move (else the one this repository's proposals land on) and <code>watch</code> lists paths whose upstream changes are flagged.</p>
103 103
  <button type="submit">SAVE</button>
104 104
</form>
105 +
<form class="card wide" id="backup" method="post" action="{{ repo.url }}/settings/backup">
106 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
107 +
  <h2>Backup</h2>
108 +
  {% if let Some(b) = backup_owner %}
109 +
  <p>{{ row.owner }} backs up {% if b.every == "off" %}only by hand or when a repository asks{% else %}{% if b.every == "hourly" %}every hour{% else %}every day{% endif %}{% endif %} to {% if b.destination == "gcs" %}<code>gs://{{ b.target }}</code>{% else %}a directory on this hub's host{% endif %}. Every repository goes with it unless it says otherwise here.</p>
110 +
  {% else %}
111 +
  <p>{{ row.owner }} has no backup set up yet: a user sets one up in <a href="/settings/backups">Settings › Backups</a>, an organisation's owners in its settings. What you choose here applies once it has.</p>
112 +
  {% endif %}
113 +
  <div class="choice">
114 +
    {% for (v, label) in backup_choices() %}<label><input type="radio" name="backup" value="{{ v }}"{% if row.backup == *v %} checked{% endif %}> {{ label }}</label>{% endfor %}
115 +
  </div>
116 +
  <p class="small mute">A repository with its own schedule makes its owner's backup run at least that often; each run sends only what changed and keeps every repository, so any run restores everything. Left out, it is in no new run; earlier runs keep it until they are pruned.</p>
117 +
  <button type="submit">SAVE</button>
118 +
</form>
105 119
{% if role == crate::auth::Role::Admin %}
106 120
<form class="card wide" id="coverage" method="post" action="{{ repo.url }}/settings/coverage">
107 121
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
123 unchanged lines