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