worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // Repository lifecycle, shared by every facade. |
| 2 | // |
| 3 | // This module exists because REST and MCP each had their own copy of create and |
| 4 | // delete, and they had already drifted: the MCP copy had no rollback (so a failed |
| 5 | // `git init` wedged the name with a permanent `Creating` record), did not support |
| 6 | // protected refs or import, and left run records behind on delete — which meant |
| 7 | // re-creating a name resurrected the previous repository's CI history. |
| 8 | // |
| 9 | // Facades now translate their wire format into `CreateSpec` and call in here. |
| 10 | |
| 11 | use crate::account::token::now_secs; |
| 12 | use crate::account::Identity; |
| 13 | use crate::error::{Error, Result}; |
| 14 | use crate::git::validate::{self, Name}; |
| 15 | use crate::git::{self, exec}; |
| 16 | use crate::http::AppState; |
| 17 | use crate::store::{RepoRecord, RepoState, Visibility}; |
| 18 | use serde::Serialize; |
| 19 | use std::sync::Arc; |
| 20 | |
| 21 | /// What a caller may specify when creating a repository. |
| 22 | #[derive(Debug, Default)] |
| 23 | pub struct CreateSpec { |
| 24 | pub name: String, |
| 25 | pub default_branch: Option<String>, |
| 26 | pub description: Option<String>, |
| 27 | pub protected_refs: Vec<String>, |
| 28 | pub import_url: Option<String>, |
| 29 | /// Absent means private. Nothing becomes readable by strangers by omission. |
| 30 | pub visibility: Option<Visibility>, |
| 31 | } |
| 32 | |
| 33 | /// The wire representation of a repository. One struct, so REST and MCP cannot |
| 34 | /// disagree about which fields exist. |
| 35 | #[derive(Debug, Serialize)] |
| 36 | pub struct RepoView { |
| 37 | pub account: String, |
| 38 | pub name: String, |
| 39 | pub display_name: String, |
| 40 | pub default_branch: String, |
| 41 | #[serde(skip_serializing_if = "Option::is_none")] |
| 42 | pub description: Option<String>, |
| 43 | pub created_at: u64, |
| 44 | pub visibility: &'static str, |
| 45 | pub protected_refs: Vec<String>, |
| 46 | pub clone_url_http: String, |
| 47 | #[serde(skip_serializing_if = "Option::is_none")] |
| 48 | pub clone_url_ssh: Option<String>, |
| 49 | } |
| 50 | |
| 51 | impl RepoView { |
| 52 | /// Build a view. `protected_refs` costs a subprocess, so list endpoints pass |
| 53 | /// `None` rather than paying it per row. |
| 54 | pub fn of(record: RepoRecord, state: &AppState, protected_refs: Vec<String>) -> Self { |
| 55 | RepoView { |
| 56 | clone_url_http: format!( |
| 57 | "{}/{}/{}.git", |
| 58 | state.config.public_host(), |
| 59 | record.account, |
| 60 | record.name |
| 61 | ), |
| 62 | clone_url_ssh: state |
| 63 | .config |
| 64 | .public_ssh_base() |
| 65 | .map(|base| format!("{base}/{}/{}.git", record.account, record.name)), |
| 66 | account: record.account, |
| 67 | name: record.name, |
| 68 | display_name: record.display_name, |
| 69 | default_branch: record.default_branch, |
| 70 | description: record.description, |
| 71 | created_at: record.created_at, |
| 72 | visibility: record.visibility.as_str(), |
| 73 | protected_refs, |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | pub fn to_value(&self) -> serde_json::Value { |
| 78 | serde_json::to_value(self).unwrap_or(serde_json::Value::Null) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | /// Create a repository, optionally seeded from a remote. |
| 83 | /// |
| 84 | /// A `Creating` marker is written before anything touches disk and flipped to |
| 85 | /// `Ready` only once the repository is usable, so a crash leaves a state `fsck` can |
| 86 | /// resolve rather than a name that is simultaneously 404 and 409. |
| 87 | pub async fn create( |
| 88 | state: &Arc<AppState>, |
| 89 | identity: &Identity, |
| 90 | spec: CreateSpec, |
| 91 | ) -> Result<RepoView> { |
| 92 | let repo = validate::name(&spec.name)?; |
| 93 | identity.require_write(&identity.account, &repo)?; |
| 94 | state.ensure_space()?; |
| 95 | state.check_repo_quota(&identity.account)?; |
| 96 | |
| 97 | let default_branch = spec |
| 98 | .default_branch |
| 99 | .unwrap_or_else(|| "refs/heads/main".to_string()); |
| 100 | validate::ref_name(&default_branch)?; |
| 101 | if !default_branch.starts_with("refs/heads/") { |
| 102 | return Err(Error::invalid( |
| 103 | "invalid-ref", |
| 104 | "default_branch must be under refs/heads/", |
| 105 | )); |
| 106 | } |
| 107 | for name in &spec.protected_refs { |
| 108 | validate::ref_name(name)?; |
| 109 | } |
| 110 | |
| 111 | let account = identity.account.clone(); |
| 112 | |
| 113 | match state.meta.get_repo(&account, &repo)? { |
| 114 | Some(existing) if existing.is_ready() => { |
| 115 | return Err(Error::conflict( |
| 116 | "repo-exists", |
| 117 | format!("{} already exists", existing.display_name), |
| 118 | )); |
| 119 | } |
| 120 | // A leftover marker is a previous crash. Reclaim rather than wedge the name. |
| 121 | Some(_) => eprintln!("[repo] reclaiming a half-created {account}/{repo}"), |
| 122 | None => {} |
| 123 | } |
| 124 | |
| 125 | let mut record = RepoRecord { |
| 126 | account: account.as_str().to_string(), |
| 127 | name: repo.as_str().to_string(), |
| 128 | display_name: spec.name.clone(), |
| 129 | default_branch: default_branch.clone(), |
| 130 | description: spec.description, |
| 131 | created_at: now_secs(), |
| 132 | state: RepoState::Creating, |
| 133 | visibility: spec.visibility.unwrap_or_default(), |
| 134 | }; |
| 135 | state.meta.put_repo(&record)?; |
| 136 | |
| 137 | if let Err(e) = materialise(state, &account, &repo, &default_branch, spec.import_url).await { |
| 138 | // Any partial directory is moved aside, not left to be served as ready. |
| 139 | let _ = state.git.soft_delete(&account, &repo); |
| 140 | let _ = state.meta.delete_repo(&account, &repo); |
| 141 | return Err(e); |
| 142 | } |
| 143 | |
| 144 | // An import brings its own HEAD, so the record must reflect what arrived. |
| 145 | if let Some(head) = state.git.head_ref(&account, &repo) { |
| 146 | record.default_branch = head; |
| 147 | } |
| 148 | |
| 149 | if !spec.protected_refs.is_empty() { |
| 150 | let path = state.git.repo_path(&account, &repo)?; |
| 151 | let refs = spec.protected_refs.clone(); |
| 152 | exec::blocking(move || git::store::set_protected_refs(&path, &refs)).await?; |
| 153 | } |
| 154 | |
| 155 | record.state = RepoState::Ready; |
| 156 | if let Err(e) = state.meta.put_repo(&record) { |
| 157 | let _ = state.git.soft_delete(&account, &repo); |
| 158 | return Err(e); |
| 159 | } |
| 160 | |
| 161 | let protected = spec.protected_refs; |
| 162 | Ok(RepoView::of(record, state, protected)) |
| 163 | } |
| 164 | |
| 165 | /// Put a repository on disk, either fresh or cloned from a remote. |
| 166 | async fn materialise( |
| 167 | state: &Arc<AppState>, |
| 168 | account: &Name, |
| 169 | repo: &Name, |
| 170 | default_branch: &str, |
| 171 | import_url: Option<String>, |
| 172 | ) -> Result<()> { |
| 173 | if state.git.exists(account, repo)? { |
| 174 | return Ok(()); |
| 175 | } |
| 176 | |
| 177 | match import_url { |
| 178 | None => { |
| 179 | let store = state.git.clone(); |
| 180 | let (a, r, branch) = (account.clone(), repo.clone(), default_branch.to_string()); |
| 181 | exec::blocking(move || store.create(&a, &r, &branch).map(|_| ())).await |
| 182 | } |
| 183 | Some(url) => import(state, account, repo, &url).await, |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | /// Seed a new repository from a remote. |
| 188 | /// |
| 189 | /// This is the only place the service fetches a caller-supplied URL, so it is the |
| 190 | /// only place SSRF is possible: `git clone` would otherwise happily read the cloud |
| 191 | /// metadata endpoint on our behalf. |
| 192 | async fn import(state: &Arc<AppState>, account: &Name, repo: &Name, url: &str) -> Result<()> { |
| 193 | if !state.config.import_enabled { |
| 194 | return Err(Error::conflict( |
| 195 | "import-disabled", |
| 196 | "importing is not enabled on this server", |
| 197 | )); |
| 198 | } |
| 199 | |
| 200 | let source = git::import::parse(url)?; |
| 201 | git::import::check_resolves_publicly(&source, state.config.import_allow_private)?; |
| 202 | |
| 203 | let path = state.git.repo_path(account, repo)?; |
| 204 | let parent = path.parent().expect("repo path has a parent"); |
| 205 | std::fs::create_dir_all(parent) |
| 206 | .map_err(|e| Error::Internal(anyhow::Error::from(e).context("create account dir")))?; |
| 207 | |
| 208 | let timeout = state.config.import_timeout; |
| 209 | let max_bytes = state.config.max_pack_bytes; |
| 210 | let target = path.clone(); |
| 211 | exec::blocking(move || git::import::fetch(&source, &target, timeout, max_bytes)).await?; |
| 212 | |
| 213 | // An imported repository is untrusted input and must end up indistinguishable |
| 214 | // from one we created: same hooks, same receive limits, same maintenance. |
| 215 | state.git.adopt(account, repo) |
| 216 | } |
| 217 | |
| 218 | /// Delete a repository and everything derived from it. |
| 219 | /// |
| 220 | /// Disk first, then metadata, then runs: a crash between them leaves a state `fsck` |
| 221 | /// reports, whereas the reverse leaves an unreachable repository and a 500. |
| 222 | pub fn delete(state: &AppState, identity: &Identity, account: &Name, repo: &Name) -> Result<()> { |
| 223 | identity.require_repo_admin(account, repo)?; |
| 224 | |
| 225 | if state.meta.get_ready_repo(account, repo)?.is_none() { |
| 226 | return Err(Error::NotFound("repository")); |
| 227 | } |
| 228 | |
| 229 | state.git.soft_delete(account, repo)?; |
| 230 | state.meta.delete_repo(account, repo)?; |
| 231 | // Without this, re-creating the name resurrects the old repository's CI history. |
| 232 | state.runs.remove_repo(account, repo); |
| 233 | Ok(()) |
| 234 | } |
| 235 | |
| 236 | /// Read one repository, including the fields that cost a subprocess. |
| 237 | pub fn get(state: &AppState, identity: &Identity, account: &Name, repo: &Name) -> Result<RepoView> { |
| 238 | let (record, path) = state.open_repo(identity, account, repo, crate::core::Access::Read)?; |
| 239 | Ok(RepoView::of( |
| 240 | record, |
| 241 | state, |
| 242 | git::store::protected_refs(&path), |
| 243 | )) |
| 244 | } |
| 245 | |
| 246 | /// List an account's repositories. |
| 247 | /// |
| 248 | /// `protected_refs` is omitted here rather than spawning `git config` once per row: |
| 249 | /// a list projection should not cost a subprocess per item. |
| 250 | pub fn list(state: &AppState, identity: &Identity) -> Result<Vec<RepoView>> { |
| 251 | Ok(state |
| 252 | .meta |
| 253 | .list_repos(&identity.account)? |
| 254 | .into_iter() |
| 255 | .filter(RepoRecord::is_ready) |
| 256 | .filter( |
| 257 | |r| match (validate::name(&r.account), validate::name(&r.name)) { |
| 258 | (Ok(a), Ok(n)) => identity.require_read(&a, &n).is_ok(), |
| 259 | _ => false, |
| 260 | }, |
| 261 | ) |
| 262 | .map(|r| RepoView::of(r, state, Vec::new())) |
| 263 | .collect()) |
| 264 | } |