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