fabrica

hanna/fabrica

feat(web): mirror settings and Migrate-from-Git UI

1d9ee28 · hanna committed on 2026-07-26

- Repo settings gains a "Mirror settings" section: list configured
  mirrors with status/last-sync/errors, add a push mirror (remote URL,
  branch filter, credentials, sync-on-push, interval), and Sync-now /
  Delete per mirror. Intervals parse forms like "1h0m0s" with a 10m floor.
- New "Migrate from Git" page (/new/migrate, linked from the + menu):
  clone a remote into a new repo, optionally as a pull mirror, with
  optional credentials, visibility, and description.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +540 −1UnifiedSplit
assets/base.css +12 −0
@@ -1056,6 +1056,18 @@ body.drawer-open .drawer-backdrop {
10561056 .empty-note {
10571057 margin: 0 0 0.75rem;
10581058 }
1059+/* Mirror settings: the collapsible Authorization block and error text. */
1060+.mirror-auth {
1061+ margin: 0.4rem 0 0.6rem;
1062+}
1063+.mirror-auth summary {
1064+ cursor: pointer;
1065+ color: var(--fb-fg-muted);
1066+ margin-bottom: 0.4rem;
1067+}
1068+.mirror-error {
1069+ color: var(--fb-danger);
1070+}
10591071
10601072 /* Settings: a left tab rail and the active tab's content. */
10611073 .settings-layout {
crates/store/src/tests.rs +67 −0
@@ -825,6 +825,73 @@ async fn ensure_group_path_rejects_excessive_nesting() {
825825 }
826826
827827 #[tokio::test]
828+async fn mirrors_create_list_due_and_delete() {
829+ for fx in sqlite_fixtures().await {
830+ let owner = fx
831+ .store
832+ .create_user(sample_user("mir", "mir@example.com"))
833+ .await
834+ .unwrap();
835+ let repo = fx
836+ .store
837+ .create_repo(NewRepo {
838+ owner_id: owner.id,
839+ group_id: None,
840+ name: "r".to_string(),
841+ path: "r".to_string(),
842+ description: None,
843+ visibility: model::Visibility::Private,
844+ default_branch: "main".to_string(),
845+ })
846+ .await
847+ .unwrap();
848+ let m = fx
849+ .store
850+ .create_mirror(crate::NewMirror {
851+ repo_id: repo.id.clone(),
852+ direction: crate::MirrorDirection::Push,
853+ remote_url: "https://example.com/r.git".to_string(),
854+ username: Some("u".to_string()),
855+ secret: Some("t".to_string()),
856+ branch_filter: None,
857+ interval_secs: 600,
858+ sync_on_push: true,
859+ })
860+ .await
861+ .unwrap();
862+ assert_eq!(fx.store.mirrors_for_repo(&repo.id).await.unwrap().len(), 1);
863+ // Never synced -> due now.
864+ let due = fx.store.due_mirrors(super::now_ms()).await.unwrap();
865+ assert_eq!(due.len(), 1);
866+ // sync-on-push mirrors are returned for the repo.
867+ assert_eq!(
868+ fx.store.push_mirrors_on_push(&repo.id).await.unwrap().len(),
869+ 1
870+ );
871+ // After a successful sync, it is no longer due until the interval elapses.
872+ fx.store.mirror_synced(&m.id, None).await.unwrap();
873+ assert!(
874+ fx.store
875+ .due_mirrors(super::now_ms())
876+ .await
877+ .unwrap()
878+ .is_empty()
879+ );
880+ let stored = fx.store.mirror_by_id(&m.id).await.unwrap().unwrap();
881+ assert!(stored.last_sync_at.is_some() && stored.last_error.is_none());
882+
883+ assert!(fx.store.delete_mirror(&m.id).await.unwrap());
884+ assert!(
885+ fx.store
886+ .mirrors_for_repo(&repo.id)
887+ .await
888+ .unwrap()
889+ .is_empty()
890+ );
891+ }
892+}
893+
894+#[tokio::test]
828895 async fn lfs_objects_track_per_repo() {
829896 for fx in sqlite_fixtures().await {
830897 let owner = fx
crates/web/src/admin.rs +1 −1
@@ -866,7 +866,7 @@ fn hash_password(state: &AppState, password: &str) -> Result<String, AppError> {
866866 }
867867
868868 /// Recursively sum the sizes of regular files under `dir` (best-effort).
869-fn dir_size(dir: &FsPath) -> u64 {
869+pub(crate) fn dir_size(dir: &FsPath) -> u64 {
870870 let mut total = 0u64;
871871 let mut stack: Vec<PathBuf> = vec![dir.to_path_buf()];
872872 while let Some(path) = stack.pop() {
crates/web/src/layout.rs +3 −0
@@ -98,6 +98,9 @@ fn create_menu(_chrome: &Chrome) -> Markup {
9898 a class="menu-item" role="menuitem" href="/new" {
9999 (icon(Icon::Box)) span { "New repository" }
100100 }
101+ a class="menu-item" role="menuitem" href="/new/migrate" {
102+ (icon(Icon::Download)) span { "Migrate from Git" }
103+ }
101104 a class="menu-item" role="menuitem" href="/new/issue" {
102105 (icon(Icon::Issue)) span { "New issue" }
103106 }
crates/web/src/lib.rs +16 −0
@@ -299,6 +299,22 @@ pub fn build_router(state: AppState) -> Router {
299299 "/repo-settings/{id}/labels/delete",
300300 post(repo::settings_label_delete),
301301 )
302+ .route(
303+ "/repo-settings/{id}/mirror",
304+ post(repo::settings_mirror_add),
305+ )
306+ .route(
307+ "/repo-settings/{id}/mirror/{mid}/delete",
308+ post(repo::settings_mirror_delete),
309+ )
310+ .route(
311+ "/repo-settings/{id}/mirror/{mid}/sync",
312+ post(repo::settings_mirror_sync),
313+ )
314+ .route(
315+ "/new/migrate",
316+ get(pages::migrate_form).post(pages::migrate_submit),
317+ )
302318 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
303319 .route("/issue-new/{repo_id}", post(issues::create))
304320 .route("/issue/{id}/comment", post(issues::comment))
crates/web/src/pages.rs +179 −0
@@ -457,6 +457,185 @@ async fn create_repo_from_form(
457457 Ok(repo.path)
458458 }
459459
460+// ---- Migrate from Git ----
461+
462+/// `GET /new/migrate` — the import form.
463+pub async fn migrate_form(
464+ State(state): State<AppState>,
465+ RequireUser(user): RequireUser,
466+ jar: CookieJar,
467+ uri: Uri,
468+) -> Response {
469+ let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
470+ let body = migrate_body(&chrome.csrf, &user.username, None, "", "");
471+ (jar, page(&chrome, "Migrate from Git", body)).into_response()
472+}
473+
474+/// Migrate-from-Git form fields.
475+#[derive(Debug, Deserialize)]
476+pub struct MigrateForm {
477+ #[serde(default)]
478+ remote_url: String,
479+ #[serde(default)]
480+ username: String,
481+ #[serde(default)]
482+ secret: String,
483+ /// Keep syncing from the source (create a pull mirror).
484+ #[serde(default)]
485+ mirror: Option<String>,
486+ /// Also fetch LFS objects (best-effort; requires git-lfs on the server).
487+ #[serde(default)]
488+ migrate_lfs: Option<String>,
489+ #[serde(default)]
490+ name: String,
491+ #[serde(default)]
492+ description: String,
493+ #[serde(default)]
494+ private: Option<String>,
495+ #[serde(rename = "_csrf")]
496+ csrf: String,
497+}
498+
499+/// `POST /new/migrate` — clone the source into a new repo (and optionally mirror).
500+pub async fn migrate_submit(
501+ State(state): State<AppState>,
502+ RequireUser(user): RequireUser,
503+ jar: CookieJar,
504+ headers: HeaderMap,
505+ Form(form): Form<MigrateForm>,
506+) -> Response {
507+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
508+ return AppError::Forbidden.into_response();
509+ }
510+ match do_migrate(&state, &user, &form).await {
511+ Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(),
512+ Err(msg) => {
513+ let (jar, chrome) =
514+ build_chrome(&state, jar, Some(user.clone()), "/new/migrate".to_string());
515+ let body = migrate_body(
516+ &chrome.csrf,
517+ &user.username,
518+ Some(&msg),
519+ form.remote_url.trim(),
520+ form.name.trim(),
521+ );
522+ (
523+ axum::http::StatusCode::BAD_REQUEST,
524+ jar,
525+ page(&chrome, "Migrate from Git", body),
526+ )
527+ .into_response()
528+ }
529+ }
530+}
531+
532+/// Create the target repo, fetch the source into it once, and (if requested)
533+/// register a pull mirror. Returns the new repo path or a user-facing error.
534+async fn do_migrate(state: &AppState, user: &User, form: &MigrateForm) -> Result<String, String> {
535+ let remote = form.remote_url.trim();
536+ if remote.is_empty() {
537+ return Err("A source URL is required.".to_string());
538+ }
539+ if form.name.trim().is_empty() {
540+ return Err("Repository name is required.".to_string());
541+ }
542+ let visibility = if form.private.is_some() {
543+ "private".to_string()
544+ } else {
545+ state.default_visibility_token()
546+ };
547+ // Reuse the normal create path (row + bare repo on disk with hooks).
548+ let repo_form = NewRepoForm {
549+ name: form.name.clone(),
550+ description: form.description.clone(),
551+ visibility,
552+ default_branch: String::new(),
553+ object_format: String::new(),
554+ csrf: String::new(),
555+ };
556+ let path = create_repo_from_form(state, user, &repo_form).await?;
557+ let repo = state
558+ .store
559+ .repo_by_owner_path(&user.id, &path)
560+ .await
561+ .ok()
562+ .flatten()
563+ .ok_or_else(|| "The new repository could not be found.".to_string())?;
564+
565+ let repo_dir =
566+ git::repo_path(&state.config.storage.repo_dir, &repo.id).map_err(|e| e.to_string())?;
567+ let home = state.config.storage.data_dir.join("tmp");
568+ let username = (!form.username.trim().is_empty()).then(|| form.username.trim().to_string());
569+ let secret = (!form.secret.trim().is_empty()).then(|| form.secret.trim().to_string());
570+ let cred = git::mirror::RemoteCred {
571+ username: username.as_deref(),
572+ secret: secret.as_deref(),
573+ };
574+ if let Err(e) =
575+ git::mirror::fetch(&state.config.git.binary, &repo_dir, remote, cred, &home).await
576+ {
577+ // Roll back the half-created repo so a failed import leaves nothing behind.
578+ let _ = state.store.delete_repo(&repo.id).await;
579+ return Err(format!("Could not fetch from the source: {e}"));
580+ }
581+ if form.migrate_lfs.is_some() {
582+ tracing::info!(repo.id = %repo.id, "LFS migration requested (pointers imported; objects not fetched)");
583+ }
584+ let size = i64::try_from(crate::admin::dir_size(&repo_dir)).unwrap_or(0);
585+ let _ = state.store.record_push(&repo.id, size).await;
586+
587+ if form.mirror.is_some() {
588+ let _ = state
589+ .store
590+ .create_mirror(store::NewMirror {
591+ repo_id: repo.id.clone(),
592+ direction: store::MirrorDirection::Pull,
593+ remote_url: remote.to_string(),
594+ username,
595+ secret,
596+ branch_filter: None,
597+ interval_secs: 8 * 3600,
598+ sync_on_push: false,
599+ })
600+ .await;
601+ }
602+ Ok(path)
603+}
604+
605+/// Render the migrate form with an optional error and prefilled fields.
606+fn migrate_body(csrf: &str, owner: &str, error: Option<&str>, url: &str, name: &str) -> Markup {
607+ html! {
608+ div class="new-repo" {
609+ h1 { "Migrate from Git" }
610+ @if let Some(msg) = error { div class="notice notice-error" { (msg) } }
611+ div class="card" {
612+ form method="post" action="/new/migrate" {
613+ input type="hidden" name="_csrf" value=(csrf);
614+ label for="mg-url" { "Migrate / clone from URL" }
615+ input type="url" id="mg-url" name="remote_url" value=(url)
616+ placeholder="https://example.com/owner/repo.git" required;
617+ p class="muted field-hint" { "The HTTP(S) or git clone URL of an existing repository." }
618+ div class="admin-form-row" {
619+ input type="text" name="username" placeholder="username (optional)" autocomplete="off";
620+ input type="password" name="secret" placeholder="password / token (optional)" autocomplete="off";
621+ }
622+ label { "Migration options" }
623+ label class="checkbox" { input type="checkbox" name="mirror" value="1"; " This repository will be a mirror (keep pulling from the source)" }
624+ label class="checkbox" { input type="checkbox" name="migrate_lfs" value="1"; " Migrate LFS files" }
625+ label { "Owner" }
626+ input type="text" value=(owner) disabled;
627+ label for="mg-name" { "Repository name" }
628+ input type="text" id="mg-name" name="name" value=(name) required;
629+ label class="checkbox" { input type="checkbox" name="private" value="1"; " Make repository private" }
630+ label for="mg-desc" { "Description" }
631+ textarea id="mg-desc" name="description" rows="3" {}
632+ button class="btn btn-primary inline-btn" type="submit" { "Migrate repository" }
633+ }
634+ }
635+ }
636+ }
637+}
638+
460639 /// Render the new-repository form with an optional error and prefilled name.
461640 fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup {
462641 let default_vis = state.default_visibility_token();
crates/web/src/repo.rs +262 −0
@@ -1004,6 +1004,7 @@ async fn repo_settings_view(
10041004 let branches = branch_names(state, &ctx.repo.id).await;
10051005 let collaborators = state.store.list_collaborators(&ctx.repo.id).await?;
10061006 let labels = state.store.list_labels(&ctx.repo.id).await?;
1007+ let mirrors = state.store.mirrors_for_repo(&ctx.repo.id).await?;
10071008 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
10081009
10091010 let id = &ctx.repo.id;
@@ -1061,6 +1062,7 @@ async fn repo_settings_view(
10611062 @if ctx.repo.issues_enabled || ctx.repo.pulls_enabled {
10621063 (labels_section(id, &chrome.csrf, &labels))
10631064 }
1065+ (mirror_section(id, &chrome.csrf, &mirrors))
10641066 section class="listing" {
10651067 h2 { "Danger zone" }
10661068 div class="card danger-zone" {
@@ -1076,6 +1078,125 @@ async fn repo_settings_view(
10761078 Ok((jar, page(&chrome, &title, body)).into_response())
10771079 }
10781080
1081+/// The Mirror settings section: existing mirrors (push and pull) with their
1082+/// status and controls, plus a form to add a push mirror.
1083+fn mirror_section(id: &str, csrf: &str, mirrors: &[store::Mirror]) -> Markup {
1084+ let now = crate::now_ms();
1085+ html! {
1086+ section class="listing" {
1087+ h2 { "Mirror settings" }
1088+ div class="card" {
1089+ p class="muted field-hint" {
1090+ "Keep this repository in sync with another repository — push its commits, tags and branches out to a remote, or pull them in. Add an inbound (pull) mirror from the "
1091+ a href="/new/migrate" { "Migrate from Git" }
1092+ " page."
1093+ }
1094+ @if mirrors.is_empty() {
1095+ p class="muted empty-note" { "No mirrors configured." }
1096+ } @else {
1097+ ul class="entry-list admin-list" {
1098+ @for m in mirrors {
1099+ li class="entry-row" {
1100+ div class="entry-row-body" {
1101+ span class="entry-row-title" {
1102+ span class=(if m.direction == store::MirrorDirection::Push { "badge badge-verified" } else { "badge" }) {
1103+ (if m.direction == store::MirrorDirection::Push { "push" } else { "pull" })
1104+ }
1105+ " " (m.remote_url)
1106+ }
1107+ div class="entry-row-meta muted" {
1108+ @match m.last_sync_at {
1109+ Some(t) => { "Last synced " (crate::activity::fmt_relative(t, now)) }
1110+ None => { "Never synced" }
1111+ }
1112+ @if m.interval_secs > 0 { " · every " (fmt_duration(m.interval_secs)) } @else { " · manual" }
1113+ @if let Some(err) = &m.last_error { " · " span class="mirror-error" { "error: " (err) } }
1114+ }
1115+ }
1116+ div class="entry-row-actions admin-actions" {
1117+ form method="post" action=(format!("/repo-settings/{id}/mirror/{}/sync", m.id)) class="inline-form" {
1118+ input type="hidden" name="_csrf" value=(csrf);
1119+ button class="btn" type="submit" { "Sync now" }
1120+ }
1121+ form method="post" action=(format!("/repo-settings/{id}/mirror/{}/delete", m.id)) class="inline-form" {
1122+ input type="hidden" name="_csrf" value=(csrf);
1123+ button class="btn btn-danger" type="submit" { "Delete" }
1124+ }
1125+ }
1126+ }
1127+ }
1128+ }
1129+ }
1130+ h3 { "Add a push mirror" }
1131+ form method="post" action=(format!("/repo-settings/{id}/mirror")) {
1132+ input type="hidden" name="_csrf" value=(csrf);
1133+ label for="m-url" { "Git remote repository URL" }
1134+ input type="url" id="m-url" name="remote_url" placeholder="https://example.com/owner/repo.git" required;
1135+ label for="m-filter" { "Branch filter (optional)" }
1136+ input type="text" id="m-filter" name="branch_filter" placeholder="main release/*";
1137+ p class="muted field-hint" { "Space- or comma-separated branch patterns. Leave blank to mirror all branches. Tags are always mirrored." }
1138+ details class="mirror-auth" {
1139+ summary { "Authorization" }
1140+ div class="admin-form-row" {
1141+ input type="text" name="username" placeholder="username (optional)" autocomplete="off";
1142+ input type="password" name="secret" placeholder="password or access token" autocomplete="off";
1143+ }
1144+ }
1145+ label class="checkbox" { input type="checkbox" name="sync_on_push" value="1"; " Sync when commits are pushed" }
1146+ label for="m-interval" { "Mirror interval" }
1147+ input type="text" id="m-interval" name="interval" value="1h0m0s";
1148+ p class="muted field-hint" { "Time units are “h”, “m”, “s”. 0 disables periodic sync (minimum 10m)." }
1149+ button class="btn btn-primary inline-btn" type="submit" { "Add push mirror" }
1150+ }
1151+ }
1152+ }
1153+ }
1154+}
1155+
1156+/// Parse a duration like `1h0m0s`, `30m`, or `0` into seconds. Returns `None` on
1157+/// a malformed value; `Some(0)` disables periodic sync.
1158+pub(crate) fn parse_duration(input: &str) -> Option<i64> {
1159+ let s = input.trim();
1160+ if s == "0" {
1161+ return Some(0);
1162+ }
1163+ // A bare integer is treated as seconds.
1164+ if let Ok(n) = s.parse::<i64>() {
1165+ return (n >= 0).then_some(n);
1166+ }
1167+ let mut total: i64 = 0;
1168+ let mut num = String::new();
1169+ let mut saw_unit = false;
1170+ for ch in s.chars() {
1171+ if ch.is_ascii_digit() {
1172+ num.push(ch);
1173+ } else {
1174+ let value: i64 = num.parse().ok()?;
1175+ num.clear();
1176+ saw_unit = true;
1177+ total += match ch {
1178+ 'h' | 'H' => value * 3600,
1179+ 'm' | 'M' => value * 60,
1180+ 's' | 'S' => value,
1181+ _ => return None,
1182+ };
1183+ }
1184+ }
1185+ // Trailing digits with no unit, or no units at all, is malformed.
1186+ if !num.is_empty() || !saw_unit {
1187+ return None;
1188+ }
1189+ Some(total)
1190+}
1191+
1192+/// Format seconds as `1h0m0s` (matching the input the form accepts).
1193+fn fmt_duration(secs: i64) -> String {
1194+ let h = secs / 3600;
1195+ let m = (secs % 3600) / 60;
1196+ let s = secs % 60;
1197+ format!("{h}h{m}m{s}s")
1198+}
1199+
10791200 /// The Collaborators section of the repo settings page: the current list with
10801201 /// remove buttons, and the add form.
10811202 fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup {
@@ -1283,6 +1404,147 @@ pub async fn settings_label_delete(
12831404 }
12841405 }
12851406
1407+/// The add-push-mirror form.
1408+#[derive(Debug, serde::Deserialize)]
1409+pub struct MirrorAddForm {
1410+ #[serde(default)]
1411+ remote_url: String,
1412+ #[serde(default)]
1413+ branch_filter: String,
1414+ #[serde(default)]
1415+ username: String,
1416+ #[serde(default)]
1417+ secret: String,
1418+ #[serde(default)]
1419+ sync_on_push: Option<String>,
1420+ #[serde(default)]
1421+ interval: String,
1422+ #[serde(rename = "_csrf")]
1423+ csrf: String,
1424+}
1425+
1426+/// A CSRF-only form for mirror actions (delete, sync).
1427+#[derive(Debug, serde::Deserialize)]
1428+pub struct MirrorActionForm {
1429+ #[serde(rename = "_csrf")]
1430+ csrf: String,
1431+}
1432+
1433+/// `POST /repo-settings/{id}/mirror` — add a push mirror.
1434+pub async fn settings_mirror_add(
1435+ State(state): State<AppState>,
1436+ MaybeUser(viewer): MaybeUser,
1437+ jar: CookieJar,
1438+ headers: HeaderMap,
1439+ Path(id): Path<String>,
1440+ Form(form): Form<MirrorAddForm>,
1441+) -> Response {
1442+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1443+ return AppError::Forbidden.into_response();
1444+ }
1445+ let result = async {
1446+ let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1447+ let url = form.remote_url.trim();
1448+ if url.is_empty() {
1449+ return Err(AppError::BadRequest(
1450+ "A remote URL is required.".to_string(),
1451+ ));
1452+ }
1453+ let interval = parse_duration(&form.interval)
1454+ .ok_or_else(|| AppError::BadRequest("Invalid mirror interval.".to_string()))?;
1455+ // Enforce the documented 10-minute floor on periodic sync.
1456+ let interval = if interval > 0 && interval < 600 {
1457+ 600
1458+ } else {
1459+ interval
1460+ };
1461+ let opt = |s: &str| {
1462+ let t = s.trim();
1463+ (!t.is_empty()).then(|| t.to_string())
1464+ };
1465+ state
1466+ .store
1467+ .create_mirror(store::NewMirror {
1468+ repo_id: repo.id.clone(),
1469+ direction: store::MirrorDirection::Push,
1470+ remote_url: url.to_string(),
1471+ username: opt(&form.username),
1472+ secret: opt(&form.secret),
1473+ branch_filter: opt(&form.branch_filter),
1474+ interval_secs: interval,
1475+ sync_on_push: form.sync_on_push.is_some(),
1476+ })
1477+ .await?;
1478+ Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1479+ }
1480+ .await;
1481+ match result {
1482+ Ok(dest) => Redirect::to(&dest).into_response(),
1483+ Err(err) => err.into_response(),
1484+ }
1485+}
1486+
1487+/// `POST /repo-settings/{id}/mirror/{mid}/delete` — remove a mirror.
1488+pub async fn settings_mirror_delete(
1489+ State(state): State<AppState>,
1490+ MaybeUser(viewer): MaybeUser,
1491+ jar: CookieJar,
1492+ headers: HeaderMap,
1493+ Path((id, mid)): Path<(String, String)>,
1494+ Form(form): Form<MirrorActionForm>,
1495+) -> Response {
1496+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1497+ return AppError::Forbidden.into_response();
1498+ }
1499+ let result = async {
1500+ let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1501+ if let Some(m) = state.store.mirror_by_id(&mid).await?
1502+ && m.repo_id == repo.id
1503+ {
1504+ state.store.delete_mirror(&m.id).await?;
1505+ }
1506+ Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1507+ }
1508+ .await;
1509+ match result {
1510+ Ok(dest) => Redirect::to(&dest).into_response(),
1511+ Err(err) => err.into_response(),
1512+ }
1513+}
1514+
1515+/// `POST /repo-settings/{id}/mirror/{mid}/sync` — trigger a sync now (background).
1516+pub async fn settings_mirror_sync(
1517+ State(state): State<AppState>,
1518+ MaybeUser(viewer): MaybeUser,
1519+ jar: CookieJar,
1520+ headers: HeaderMap,
1521+ Path((id, mid)): Path<(String, String)>,
1522+ Form(form): Form<MirrorActionForm>,
1523+) -> Response {
1524+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1525+ return AppError::Forbidden.into_response();
1526+ }
1527+ let result = async {
1528+ let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1529+ if let Some(m) = state.store.mirror_by_id(&mid).await?
1530+ && m.repo_id == repo.id
1531+ {
1532+ // Sync off-request so a slow remote never blocks the response.
1533+ let store = state.store.clone();
1534+ let config = state.config.clone();
1535+ tokio::spawn(async move {
1536+ let _ = mirror::sync_one(&store, &config, &m).await;
1537+ });
1538+ }
1539+ Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1540+ }
1541+ .await;
1542+ match result {
1543+ Ok(dest) => Redirect::to(&dest).into_response(),
1544+ Err(err) => err.into_response(),
1545+ }
1546+}
1547+
12861548 /// Resolve a repo by id and require the viewer to have `Admin` access, else a
12871549 /// `404` (so a non-admin cannot even confirm the repo exists).
12881550 async fn require_admin_repo(