zuka
zuka/src/api/repos.rs

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
zuka/src/api/repos.rs
RSrepos.rs7.7 KBDownload
1// Repository resource.
2//
3// The account is in the path (`/v1/repos/{account}/{repo}`) so the control plane can
4// route to the owner's container, grants are addressable, and the REST URL is
5// derivable from the clone URL (SPEC §3).
6
7use crate::account::Identity;
8use crate::core;
9use crate::error::{Error, Result};
10use crate::git;
11use crate::git::validate::{self, Name};
12use crate::http::response::Body;
13use crate::http::{request, response, AppState};
14use hyper::body::Incoming;
15use hyper::{Method, Request, Response, StatusCode};
16use serde::{Deserialize, Serialize};
17use std::sync::Arc;
18
19/// RFC 7396 merge patch. An absent field is unchanged; `null` clears.
20#[derive(Debug, Deserialize)]
21struct PatchRepo {
22 #[serde(default, deserialize_with = "double_option")]
23 description: Option<Option<String>>,
24 #[serde(default)]
25 default_branch: Option<String>,
26 #[serde(default)]
27 protected_refs: Option<Vec<String>>,
28 #[serde(default)]
29 visibility: Option<String>,
30}
31
32/// Distinguish "absent" from "present and null", which merge-patch requires.
33fn double_option<'de, D, T>(deserializer: D) -> std::result::Result<Option<Option<T>>, D::Error>
34where
35 D: serde::Deserializer<'de>,
36 T: Deserialize<'de>,
37{
38 Deserialize::deserialize(deserializer).map(Some)
39}
40
41/// Wire shape for creation. Translated into `core::repo::CreateSpec`; the rules
42/// live there so REST and MCP cannot drift.
43#[derive(Debug, Deserialize)]
44struct CreateBody {
45 name: String,
46 #[serde(default)]
47 default_branch: Option<String>,
48 #[serde(default)]
49 description: Option<String>,
50 #[serde(default)]
51 protected_refs: Vec<String>,
52 #[serde(default)]
53 import_url: Option<String>,
54 #[serde(default)]
55 visibility: Option<String>,
56}
57
58impl TryFrom<CreateBody> for core::repo::CreateSpec {
59 type Error = crate::error::Error;
60
61 fn try_from(body: CreateBody) -> crate::error::Result<Self> {
62 Ok(core::repo::CreateSpec {
63 name: body.name,
64 default_branch: body.default_branch,
65 description: body.description,
66 protected_refs: body.protected_refs,
67 import_url: body.import_url,
68 visibility: body.visibility.as_deref().map(str::parse).transpose()?,
69 })
70 }
71}
72
73pub async fn route(
74 request: Request<Incoming>,
75 state: Arc<AppState>,
76 identity: Identity,
77 tail: &[&str],
78) -> Result<Response<Body>> {
79 let method = request.method().clone();
80
81 match (&method, tail) {
82 (&Method::GET, []) => {
83 let (for_list, id) = (Arc::clone(&state), identity.clone());
84 let items = git::exec::blocking(move || core::repo::list(&for_list, &id)).await?;
85 Ok(response::json(StatusCode::OK, &page(items, false)))
86 }
87 (&Method::POST, []) => {
88 let body: CreateBody =
89 request::read_json(request.into_body(), state.config.max_body_bytes).await?;
90 let view = core::repo::create(&state, &identity, body.try_into()?).await?;
91 let location = format!("/v1/repos/{}/{}", view.account, view.name);
92 Ok(response::created(&location, &view))
93 }
94 (&Method::GET, [account, repo]) => {
95 let (a, r) = names(account, repo)?;
96 let (for_get, id) = (Arc::clone(&state), identity.clone());
97 let view = git::exec::blocking(move || core::repo::get(&for_get, &id, &a, &r)).await?;
98 Ok(response::json(StatusCode::OK, &view))
99 }
100 (&Method::PATCH, [account, repo]) => {
101 let (a, r) = names(account, repo)?;
102 patch(request, &state, &identity, &a, &r).await
103 }
104 (&Method::DELETE, [account, repo]) => {
105 let (a, r) = names(account, repo)?;
106 core::repo::delete(&state, &identity, &a, &r)?;
107 Ok(response::no_content())
108 }
109 (&Method::GET, [account, repo, "refs"]) => {
110 let (a, r) = names(account, repo)?;
111 let (_, path) = state.open_repo(&identity, &a, &r, core::Access::Read)?;
112 let kind = request
113 .uri()
114 .query()
115 .and_then(|q| request::query_param(q, "type"));
116 let refs =
117 git::exec::blocking(move || git::discover::refs(&path, kind.as_deref())).await?;
118 Ok(response::json(StatusCode::OK, &refs))
119 }
120 (_, [account, repo, rest @ ..])
121 if matches!(
122 rest.first(),
123 Some(&"tree" | &"raw" | &"commits" | &"compare" | &"blame" | &"search")
124 ) =>
125 {
126 crate::api::content::route(request, state, identity, account, repo, rest).await
127 }
128 (_, [account, repo, "runs", rest @ ..]) => {
129 crate::api::runs::route(request, state, identity, account, repo, rest).await
130 }
131 (_, []) | (_, [_, _]) | (_, [_, _, "refs"]) => Err(Error::MethodNotAllowed),
132 _ => Err(Error::NotFound("route")),
133 }
134}
135
136fn names(account: &str, repo: &str) -> Result<(Name, Name)> {
137 Ok((validate::name(account)?, validate::name(repo)?))
138}
139
140/// Create a repository.
141///
142/// A `Creating` marker is written before `git init` and flipped to `Ready` after.
143/// Without it, a crash between the two leaves a name that is simultaneously 404 (no
144/// metadata) and 409 (directory present), with no way to resolve it. The marker is
145/// also the create lock: two concurrent creates cannot both observe "absent".
146/// Update a repository's settings. `application/merge-patch+json` semantics.
147async fn patch(
148 request: Request<Incoming>,
149 state: &AppState,
150 identity: &Identity,
151 account: &Name,
152 repo: &Name,
153) -> Result<Response<Body>> {
154 let limit = state.config.max_body_bytes;
155 let body: PatchRepo = request::read_json(request.into_body(), limit).await?;
156
157 let (mut record, path) = state.open_repo(identity, account, repo, core::Access::Admin)?;
158
159 if let Some(description) = body.description {
160 record.description = description;
161 }
162
163 // Requires Admin, which `open_repo` above already enforced. Publishing a
164 // repository is not a write to its contents but it is the most consequential
165 // setting on it, and read or write scope must not be enough to flip it.
166 if let Some(visibility) = body.visibility {
167 record.visibility = visibility.parse()?;
168 }
169
170 if let Some(branch) = body.default_branch {
171 validate::ref_name(&branch)?;
172 if !branch.starts_with("refs/heads/") {
173 return Err(Error::invalid(
174 "invalid-ref",
175 "default_branch must be under refs/heads/",
176 ));
177 }
178 // Pointing HEAD at a branch that does not exist leaves a fresh clone on a
179 // detached, empty checkout.
180 let exists = state
181 .git
182 .list_refs(account, repo)?
183 .iter()
184 .any(|(name, _)| name == &branch);
185 if !exists {
186 return Err(Error::conflict(
187 "no-such-ref",
188 format!("{branch} does not exist in this repository"),
189 ));
190 }
191 git::store::set_head(&path, &branch)?;
192 record.default_branch = branch;
193 }
194
195 if let Some(refs) = body.protected_refs {
196 git::store::set_protected_refs(&path, &refs)?;
197 }
198
199 state.meta.put_repo(&record)?;
200 let protected = git::store::protected_refs(&path);
201 Ok(response::json(
202 StatusCode::OK,
203 &core::repo::RepoView::of(record, state, protected),
204 ))
205}
206
207/// The list envelope every collection endpoint uses.
208///
209/// `truncated`, not `next_cursor`: there is no cursor, and reporting `null` on a
210/// list that was cut short told clients it had ended when it had not.
211pub fn page<T: Serialize>(items: Vec<T>, truncated: bool) -> serde_json::Value {
212 serde_json::json!({ "items": items, "truncated": truncated })
213}