kitgit

tirbofish/kitgit · diff

a68c168 · Thribhu K

feat: add repository pull mirroring from remotes

Verified

diff --git a/migrations/010_repo_mirrors.sql b/migrations/010_repo_mirrors.sql
new file mode 100644
index 0000000..6d5bb5c
--- /dev/null
+++ b/migrations/010_repo_mirrors.sql
@@ -0,0 +1,15 @@
+-- Pull mirrors: fetch from a remote into the local bare repository.
+CREATE TABLE IF NOT EXISTS repo_mirrors (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    repo_id UUID NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE,
+    remote_url TEXT NOT NULL,
+    enabled BOOLEAN NOT NULL DEFAULT TRUE,
+    last_synced_at TIMESTAMPTZ,
+    last_error TEXT,
+    created_by UUID REFERENCES users(id) ON DELETE SET NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    CONSTRAINT repo_mirrors_remote_url_nonempty CHECK (char_length(trim(remote_url)) > 0)
+);
+CREATE INDEX IF NOT EXISTS repo_mirrors_enabled_idx
+    ON repo_mirrors(enabled) WHERE enabled = TRUE;
diff --git a/src/db/models.rs b/src/db/models.rs
index 874e00c..236c066 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -101,6 +101,19 @@ pub struct BranchRule {
     pub created_at: DateTime<Utc>,
 }
 
+#[derive(Debug, Clone, FromRow, Serialize)]
+pub struct RepoMirror {
+    pub id: Uuid,
+    pub repo_id: Uuid,
+    pub remote_url: String,
+    pub enabled: bool,
+    pub last_synced_at: Option<DateTime<Utc>>,
+    pub last_error: Option<String>,
+    pub created_by: Option<Uuid>,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+}
+
 #[derive(Debug, Clone, FromRow)]
 pub struct CommentReaction {
     pub comment_id: Uuid,
diff --git a/src/db/queries.rs b/src/db/queries.rs
index a236a11..10890a5 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -2349,3 +2349,82 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> {
         .await?;
     Ok(row.map(|r| r.0))
 }
+
+// ── repo mirrors ─────────────────────────────────────────────────────────────
+
+pub async fn get_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<Option<RepoMirror>> {
+    Ok(sqlx::query_as::<_, RepoMirror>(
+        "SELECT * FROM repo_mirrors WHERE repo_id = $1",
+    )
+    .bind(repo_id)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn upsert_repo_mirror(
+    pool: &PgPool,
+    repo_id: Uuid,
+    remote_url: &str,
+    enabled: bool,
+    created_by: Uuid,
+) -> Result<RepoMirror> {
+    Ok(sqlx::query_as::<_, RepoMirror>(
+        r#"
+        INSERT INTO repo_mirrors (repo_id, remote_url, enabled, created_by)
+        VALUES ($1, $2, $3, $4)
+        ON CONFLICT (repo_id) DO UPDATE SET
+            remote_url = EXCLUDED.remote_url,
+            enabled = EXCLUDED.enabled,
+            updated_at = now()
+        RETURNING *
+        "#,
+    )
+    .bind(repo_id)
+    .bind(remote_url)
+    .bind(enabled)
+    .bind(created_by)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn set_mirror_sync_result(
+    pool: &PgPool,
+    repo_id: Uuid,
+    ok: bool,
+    error: Option<&str>,
+) -> Result<RepoMirror> {
+    if ok {
+        Ok(sqlx::query_as::<_, RepoMirror>(
+            r#"
+            UPDATE repo_mirrors
+            SET last_synced_at = now(), last_error = NULL, updated_at = now()
+            WHERE repo_id = $1
+            RETURNING *
+            "#,
+        )
+        .bind(repo_id)
+        .fetch_one(pool)
+        .await?)
+    } else {
+        Ok(sqlx::query_as::<_, RepoMirror>(
+            r#"
+            UPDATE repo_mirrors
+            SET last_error = $2, updated_at = now()
+            WHERE repo_id = $1
+            RETURNING *
+            "#,
+        )
+        .bind(repo_id)
+        .bind(error)
+        .fetch_one(pool)
+        .await?)
+    }
+}
+
+pub async fn delete_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<()> {
+    sqlx::query("DELETE FROM repo_mirrors WHERE repo_id = $1")
+        .bind(repo_id)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
diff --git a/src/git/repo.rs b/src/git/repo.rs
index 06e53dd..9b0f4e0 100644
--- a/src/git/repo.rs
+++ b/src/git/repo.rs
@@ -79,6 +79,83 @@ pub fn rename_branch(repo: &G2Repo, old: &str, new: &str) -> Result<()> {
     Ok(())
 }
 
+/// Whether `url` is an allowed remote for pull-mirroring (no local/file paths).
+pub fn is_safe_mirror_url(url: &str) -> bool {
+    let url = url.trim();
+    if url.is_empty() || url.contains('\0') || url.contains('\n') || url.contains('\r') {
+        return false;
+    }
+    let lower = url.to_ascii_lowercase();
+    if lower.starts_with("file:")
+        || lower.starts_with('/')
+        || lower.starts_with("\\\\")
+        || (url.len() >= 2 && url.as_bytes()[1] == b':')
+    {
+        return false;
+    }
+    lower.starts_with("https://")
+        || lower.starts_with("http://")
+        || lower.starts_with("git://")
+        || lower.starts_with("ssh://")
+        || (url.contains('@') && url.contains(':') && !lower.contains("://"))
+}
+
+/// Fetch refs from `remote_url` into the existing bare repo (pull mirror).
+pub fn mirror_fetch(repos_root: &Path, owner: &str, name: &str, remote_url: &str) -> Result<()> {
+    if !is_safe_mirror_url(remote_url) {
+        return Err(anyhow!("unsupported or unsafe mirror URL"));
+    }
+    let path = bare_path(repos_root, owner, name);
+    if !path.exists() {
+        return Err(anyhow!("local bare repository missing"));
+    }
+    let path_str = path
+        .to_str()
+        .ok_or_else(|| anyhow!("repository path is not valid UTF-8"))?;
+    let url = remote_url.trim();
+
+    // Dedicated remote so we do not clobber a user's `origin`.
+    let _ = std::process::Command::new("git")
+        .args(["-C", path_str, "remote", "remove", "kitgit-mirror"])
+        .output();
+    let add = std::process::Command::new("git")
+        .args(["-C", path_str, "remote", "add", "kitgit-mirror", url])
+        .output()
+        .with_context(|| "git remote add")?;
+    if !add.status.success() {
+        return Err(anyhow!(
+            "git remote add failed: {}",
+            String::from_utf8_lossy(&add.stderr).trim()
+        ));
+    }
+
+    let fetch = std::process::Command::new("git")
+        .args([
+            "-C",
+            path_str,
+            "fetch",
+            "kitgit-mirror",
+            "+refs/heads/*:refs/heads/*",
+            "+refs/tags/*:refs/tags/*",
+            "--prune",
+            "--force",
+        ])
+        .output()
+        .with_context(|| "git fetch mirror")?;
+    if !fetch.status.success() {
+        return Err(anyhow!(
+            "git fetch failed: {}",
+            String::from_utf8_lossy(&fetch.stderr).trim()
+        ));
+    }
+
+    if let Ok(repo) = G2Repo::open_bare(&path) {
+        let _ = repo.config()?.set_bool("http.receivepack", true);
+        let _ = repo.config()?.set_bool("http.uploadpack", true);
+    }
+    Ok(())
+}
+
 /// Copy a bare repository on disk (for forks).
 pub fn clone_bare(src: &Path, dest: &Path) -> Result<()> {
     if dest.exists() {
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c4b4cbb..d2c2cbc 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -270,6 +270,18 @@ pub fn app_router(state: AppState) -> Router {
             post(routes_extra::branch_rule_delete),
         )
         .route(
+            "/{owner}/{repo}/settings/mirror",
+            post(routes_extra::mirror_save),
+        )
+        .route(
+            "/{owner}/{repo}/settings/mirror/sync",
+            post(routes_extra::mirror_sync),
+        )
+        .route(
+            "/{owner}/{repo}/settings/mirror/delete",
+            post(routes_extra::mirror_delete),
+        )
+        .route(
             "/{owner}/{repo}/settings/danger/archive",
             post(routes::repo_archive),
         )
diff --git a/src/web/routes.rs b/src/web/routes.rs
index c080f6f..3a5848d 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -3719,7 +3719,8 @@ pub async fn repo_settings(
 ) -> AppResult<impl IntoResponse> {
     let (repository, owner_user, viewer, access) =
         load_repo_context(&state, &owner, &repo, &headers).await?;
-    if !access.can_admin() {
+    let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false);
+    if !access.can_admin() && !is_site_admin {
         return Err(AppError::forbidden());
     }
     let collaborators = queries::list_collaborators(&state.pool, repository.id).await?;
@@ -3743,7 +3744,7 @@ pub async fn repo_settings(
     }
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
     let branch_rules = queries::list_branch_rules(&state.pool, repository.id).await?;
-    let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false);
+    let mirror = queries::get_repo_mirror(&state.pool, repository.id).await?;
     Ok(RepoSettingsTemplate {
         viewer,
         owner: owner_user,
@@ -3754,6 +3755,7 @@ pub async fn repo_settings(
         clone_http,
         clone_ssh,
         branch_rules,
+        mirror,
         is_site_admin,
         error: None,
     })
diff --git a/src/web/routes_extra.rs b/src/web/routes_extra.rs
index 712ab5f..22d302b 100644
--- a/src/web/routes_extra.rs
+++ b/src/web/routes_extra.rs
@@ -800,6 +800,114 @@ pub async fn gpg_delete(
     Ok(redirect_see_other("/settings/keys"))
 }
 
+fn can_manage_mirror(access: crate::db::models::Access, viewer: &Option<crate::db::models::User>) -> bool {
+    access.can_admin() || viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false)
+}
+
+fn truncate_err(msg: &str) -> String {
+    const MAX: usize = 2000;
+    let msg = msg.trim();
+    if msg.len() <= MAX {
+        msg.to_string()
+    } else {
+        format!("{}…", &msg[..MAX])
+    }
+}
+
+#[derive(Deserialize)]
+pub struct MirrorForm {
+    pub remote_url: String,
+    pub enabled: Option<String>,
+}
+
+pub async fn mirror_save(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+    Form(form): Form<MirrorForm>,
+) -> AppResult<Response> {
+    let (repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let user = viewer.as_ref().ok_or_else(AppError::unauthorized)?;
+    if !can_manage_mirror(access, &viewer) {
+        return Err(AppError::forbidden());
+    }
+    let url = form.remote_url.trim();
+    if url.is_empty() {
+        return Err(AppError::bad("mirror URL required"));
+    }
+    if !git::is_safe_mirror_url(url) {
+        return Err(AppError::bad(
+            "mirror URL must be http(s), git://, ssh://, or git@host:path",
+        ));
+    }
+    queries::upsert_repo_mirror(
+        &state.pool,
+        repository.id,
+        url,
+        checkbox(&form.enabled),
+        user.id,
+    )
+    .await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+pub async fn mirror_sync(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<Response> {
+    let (repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _user = viewer.as_ref().ok_or_else(AppError::unauthorized)?;
+    if !can_manage_mirror(access, &viewer) {
+        return Err(AppError::forbidden());
+    }
+    let mirror = queries::get_repo_mirror(&state.pool, repository.id)
+        .await?
+        .ok_or_else(|| AppError::bad("no mirror configured"))?;
+    if !mirror.enabled {
+        return Err(AppError::bad("mirror is disabled"));
+    }
+
+    let repos_dir = state.config.repos_dir();
+    let owner_s = owner.clone();
+    let repo_s = repo.clone();
+    let remote_url = mirror.remote_url.clone();
+    let result = tokio::task::spawn_blocking(move || {
+        git::mirror_fetch(&repos_dir, &owner_s, &repo_s, &remote_url)
+    })
+    .await
+    .map_err(|e| AppError::internal(format!("mirror sync join: {e}")))?;
+
+    match result {
+        Ok(()) => {
+            queries::set_mirror_sync_result(&state.pool, repository.id, true, None).await?;
+        }
+        Err(e) => {
+            let msg = truncate_err(&format!("{e:#}"));
+            queries::set_mirror_sync_result(&state.pool, repository.id, false, Some(&msg)).await?;
+            return Err(AppError::bad(format!("mirror sync failed: {msg}")));
+        }
+    }
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
+pub async fn mirror_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<Response> {
+    let (repository, _, viewer, access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let _user = viewer.as_ref().ok_or_else(AppError::unauthorized)?;
+    if !can_manage_mirror(access, &viewer) {
+        return Err(AppError::forbidden());
+    }
+    queries::delete_repo_mirror(&state.pool, repository.id).await?;
+    Ok(redirect_see_other(&format!("/{owner}/{repo}/settings")))
+}
+
 // silence unused import warning for avatar_url_for if not used
 #[allow(dead_code)]
 fn _use_avatar(u: &crate::db::models::User) -> String {
diff --git a/src/web/templates.rs b/src/web/templates.rs
index a2932d4..816c6f5 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -1,7 +1,7 @@
 use crate::og::SocialMeta;
 use crate::db::models::{
-    Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User,
-    UserEmail,
+    Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, RepoMirror, Repository,
+    SshKey, User, UserEmail,
 };
 use askama::Template;
 use askama_web::WebTemplate;
@@ -521,6 +521,7 @@ pub struct RepoSettingsTemplate {
     pub collaborators: Vec<CollaboratorView>,
     pub branches: Vec<String>,
     pub branch_rules: Vec<BranchRule>,
+    pub mirror: Option<RepoMirror>,
     pub clone_http: String,
     pub clone_ssh: String,
     pub is_site_admin: bool,
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
index effe71d..4cff7b7 100644
--- a/templates/repo_settings.html
+++ b/templates/repo_settings.html
@@ -146,6 +146,64 @@
   </div>
 
   <div class="kg-settings-section">
+    <span class="kg-kicker">mirror</span>
+    <p class="kg-muted">Pull from another remote into this repository (fetch heads and tags).</p>
+    {% if let Some(m) = mirror %}
+      <p class="kg-meta" style="margin:0.5rem 0">
+        {% if m.enabled %}
+          <span class="kg-badge">enabled</span>
+        {% else %}
+          <span class="kg-badge">disabled</span>
+        {% endif %}
+        {% if let Some(synced) = m.last_synced_at %}
+          · last synced {{ synced }}
+        {% else %}
+          · never synced
+        {% endif %}
+      </p>
+      {% if let Some(err) = m.last_error %}
+        <div class="kg-flash">{{ err }}</div>
+      {% endif %}
+      <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror" style="margin-top:1rem">
+        <label>
+          remote URL
+          <input type="text" name="remote_url" required value="{{ m.remote_url }}" placeholder="https://github.com/org/repo.git" />
+        </label>
+        <label class="row">
+          <input type="checkbox" name="enabled"{% if m.enabled %} checked{% endif %} />
+          enabled
+        </label>
+        <div class="kg-actions">
+          <button class="kg-btn" type="submit">save mirror</button>
+        </div>
+      </form>
+      <div class="kg-actions" style="margin-top:0.75rem;gap:0.5rem;display:flex;flex-wrap:wrap">
+        <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror/sync">
+          <button class="kg-btn kg-btn--ghost" type="submit">sync now</button>
+        </form>
+        <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror/delete">
+          <button class="kg-btn kg-btn--danger-ghost" type="submit">remove mirror</button>
+        </form>
+      </div>
+    {% else %}
+      <p class="kg-muted">No mirror configured.</p>
+      <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror" style="margin-top:1rem">
+        <label>
+          remote URL
+          <input type="text" name="remote_url" required placeholder="https://github.com/org/repo.git" />
+        </label>
+        <label class="row">
+          <input type="checkbox" name="enabled" checked />
+          enabled
+        </label>
+        <div class="kg-actions">
+          <button class="kg-btn" type="submit">save mirror</button>
+        </div>
+      </form>
+    {% endif %}
+  </div>
+
+  <div class="kg-settings-section">
     <span class="kg-kicker">collaborators</span>
     {% if collaborators.is_empty() %}
       <p class="kg-muted">No collaborators.</p>