branch: main
lib.rs
49148 bytesRaw
mod api;
mod diff;
mod git;
mod issues;
mod issues_web;
mod pack;
mod presentation;
mod schema;
mod store;
mod web;
use crate::presentation::{NegotiatedRepresentation, Representation};
use worker::*;
/// Delta compression keyframe interval. A full keyframe is stored every N
/// versions within a blob group. Worst-case reconstruction applies N-1 deltas.
pub const KEYFRAME_INTERVAL: i64 = 50;
// ---------------------------------------------------------------------------
// Identity from trusted X-Ripgit-Actor-* headers.
// These are only set by the auth worker (via service binding) and must never
// be forwarded from the public internet.
// ---------------------------------------------------------------------------
struct Actor {
display_name: String,
}
fn actor_from_request(req: &Request) -> Option<Actor> {
let name = req.headers().get("X-Ripgit-Actor-Name").ok()??;
Some(Actor { display_name: name })
}
/// Returns a deny Response if the actor cannot write to this repo, else None.
/// Ownership is checked by comparing the actor's display_name to the URL owner.
fn check_write_access(actor: &Option<Actor>, repo_owner: &str) -> Option<Result<Response>> {
match actor {
None => Some(unauthorized_401()),
Some(a) if a.display_name == repo_owner => None,
Some(_) => Some(Response::error(
"Forbidden: you don't own this repository",
403,
)),
}
}
/// 401 with WWW-Authenticate so git knows to prompt for / retry with credentials.
fn unauthorized_401() -> Result<Response> {
let mut resp = Response::error("Unauthorized: sign in to push", 401)?;
resp.headers_mut()
.set("WWW-Authenticate", r#"Basic realm="ripgit""#)?;
Ok(resp)
}
/// Build a 302 redirect using an absolute URL.
///
/// `Response::error("", 302)` + manual Location header is unreliable on some
/// Cloudflare Workers runtimes ("unrecognized JavaScript object"). Using
/// `Response::redirect()` with a proper absolute URL avoids this.
fn make_redirect(base_url: &Url, path: &str) -> Result<Response> {
let abs = format!("{}{}", base_url.origin().ascii_serialization(), path);
let url = Url::parse(&abs).map_err(|e| Error::RustError(e.to_string()))?;
Response::redirect(url)
}
fn negotiate_or_response(
req: &Request,
supported: &[Representation],
default: Representation,
) -> std::result::Result<NegotiatedRepresentation, Result<Response>> {
presentation::preferred_representation(req, supported, default)
.map_err(|err| err.into_response())
}
fn finalize_negotiated(
response: Result<Response>,
selection: &NegotiatedRepresentation,
) -> Result<Response> {
response.and_then(|resp| presentation::finalize_response(resp, selection))
}
// ---------------------------------------------------------------------------
// Worker entry point — route to the named Repository DO
// ---------------------------------------------------------------------------
#[event(fetch)]
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
let url = req.url()?;
let path = url.path();
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
// /:owner/ — user profile page (parts = ["owner", ""] with trailing slash,
// or parts = ["owner"] without). Handled at the Worker level since there
// is no per-owner DO; it just shows push instructions.
let is_owner_page = (parts.len() == 1 && !parts[0].is_empty())
|| (parts.len() == 2 && !parts[0].is_empty() && parts[1].is_empty());
if is_owner_page {
let owner = parts[0];
let actor_name = actor_from_request(&req).map(|a| a.display_name);
let url = req.url()?;
let repos = list_repos(&env, owner).await;
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
return match selection.representation() {
Representation::Html => finalize_negotiated(
web::page_owner_profile(owner, actor_name.as_deref(), &url, &repos),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_owner_profile_markdown(
owner,
actor_name.as_deref(),
&url,
&repos,
&selection,
),
&selection,
),
Representation::Json => unreachable!(),
};
}
// /:owner/:repo/* — dispatched to a DO instance named "{owner}/{repo}".
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
let do_name = format!("{}/{}", parts[0], parts[1]);
let namespace = env.durable_object("REPOSITORY")?;
let id = namespace.id_from_name(&do_name)?;
let stub = id.get_stub()?;
return stub.fetch_with_request(req).await;
}
Response::from_json(&serde_json::json!({
"name": "ripgit",
"version": "0.1.0",
"description": "Git remote backed by Cloudflare Durable Objects"
}))
}
// ---------------------------------------------------------------------------
// Repository Durable Object
// ---------------------------------------------------------------------------
#[durable_object]
pub struct Repository {
state: State,
sql: SqlStorage,
#[allow(dead_code)]
env: Env,
}
impl DurableObject for Repository {
fn new(state: State, env: Env) -> Self {
let state = state;
let sql = state.storage().sql();
schema::init(&sql);
Self { sql, env, state }
}
async fn fetch(&self, mut req: Request) -> Result<Response> {
let url = req.url()?;
let path = url.path();
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
// Minimum: [":owner", ":repo"]
if parts.len() < 2 {
return Response::error("Not Found", 404);
}
let owner = parts[0];
let repo_name = parts[1];
let action = if parts.len() >= 3 { parts[2] } else { "" };
// Resolve the caller's identity from trusted headers (set by auth worker).
// None means anonymous — allowed for reads, denied for writes.
let actor = actor_from_request(&req);
let actor_name = actor.as_ref().map(|a| a.display_name.as_str());
match (req.method(), action) {
// -- Git smart HTTP protocol --
(Method::Get, "info") if parts.get(3) == Some(&"refs") => {
let service = url
.query_pairs()
.find(|(k, _)| k == "service")
.map(|(_, v)| v.to_string())
.unwrap_or_default();
match service.as_str() {
"git-receive-pack" => {
if let Some(resp) = check_write_access(&actor, owner) {
return resp;
}
self.advertise_refs("git-receive-pack")
}
"git-upload-pack" => self.advertise_refs("git-upload-pack"),
_ => Response::error("Unsupported service", 403),
}
}
(Method::Post, "git-receive-pack") => {
if let Some(resp) = check_write_access(&actor, owner) {
return resp;
}
let body = req.bytes().await?;
let resp = git::handle_receive_pack(&self.sql, &body)?;
// On successful push, register the repo in the REGISTRY KV so
// the owner profile page can list it. Best-effort: never fail the push.
if resp.status_code() == 200 {
let key = format!("repo:{}/{}", owner, repo_name);
if let Ok(kv) = self.env.kv("REGISTRY") {
if let Ok(builder) = kv.put(&key, "1") {
let _ = builder.execute().await;
}
}
}
Ok(resp)
}
(Method::Post, "git-upload-pack") => {
let body = req.bytes().await?;
git::handle_upload_pack(&self.sql, &body)
}
// -- Delete all data (owner only) --
(Method::Delete, "") => {
if let Some(resp) = check_write_access(&actor, owner) {
return resp;
}
self.state.storage().delete_all().await?;
Response::ok("deleted")
}
// -- JSON API (always JSON) --
(Method::Get, "refs") => api::handle_refs(&self.sql),
(Method::Get, "file") => api::handle_file(&self.sql, &url),
(Method::Get, "search") => api::handle_search(&self.sql, &url),
(Method::Get, "stats") => api::handle_stats(&self.sql),
// -- Diff / commit history --
(Method::Get, "diff") => {
let sha = parts.get(3).unwrap_or(&"");
let selection = match negotiate_or_response(
&req,
&[
Representation::Json,
Representation::Html,
Representation::Markdown,
],
Representation::Json,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Json => {
finalize_negotiated(diff::handle_diff(&self.sql, sha, &url), &selection)
}
Representation::Html => finalize_negotiated(
web::page_commit(&self.sql, owner, repo_name, sha, actor_name),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_diff_markdown(&self.sql, owner, repo_name, sha, &selection),
&selection,
),
}
}
(Method::Get, "compare") => {
let spec = parts.get(3).unwrap_or(&"");
diff::handle_compare(&self.sql, spec, &url)
}
(Method::Get, "log") => {
let selection = match negotiate_or_response(
&req,
&[
Representation::Json,
Representation::Html,
Representation::Markdown,
],
Representation::Json,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Json => {
finalize_negotiated(api::handle_log(&self.sql, &url), &selection)
}
Representation::Html => finalize_negotiated(
web::page_log(&self.sql, owner, repo_name, &url, actor_name),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_log_markdown(&self.sql, owner, repo_name, &url, &selection),
&selection,
),
}
}
(Method::Get, "commit") => {
let hash = parts.get(3).unwrap_or(&"");
let selection = match negotiate_or_response(
&req,
&[
Representation::Json,
Representation::Html,
Representation::Markdown,
],
Representation::Json,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Json => {
finalize_negotiated(api::handle_commit(&self.sql, hash), &selection)
}
Representation::Html => finalize_negotiated(
web::page_commit(&self.sql, owner, repo_name, hash, actor_name),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_commit_markdown(&self.sql, owner, repo_name, hash, &selection),
&selection,
),
}
}
// tree/blob by 40-hex hash → JSON API
(Method::Get, "tree") if is_hex40(parts.get(3).unwrap_or(&"")) => {
api::handle_tree(&self.sql, parts.get(3).unwrap_or(&""))
}
(Method::Get, "blob") if is_hex40(parts.get(3).unwrap_or(&"")) => {
api::handle_blob(&self.sql, parts.get(3).unwrap_or(&""))
}
// -- Web UI --
(Method::Get, "") => {
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Html => finalize_negotiated(
web::page_home(&self.sql, owner, repo_name, &url, actor_name),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_home_markdown(
&self.sql, owner, repo_name, &url, actor_name, &selection,
),
&selection,
),
Representation::Json => unreachable!(),
}
}
(Method::Get, "commits") => {
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Html => finalize_negotiated(
web::page_log(&self.sql, owner, repo_name, &url, actor_name),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_log_markdown(&self.sql, owner, repo_name, &url, &selection),
&selection,
),
Representation::Json => unreachable!(),
}
}
(Method::Get, "tree") => {
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
let ref_name = parts.get(3).unwrap_or(&"main");
let sub_path = if parts.len() > 4 {
parts[4..].join("/")
} else {
String::new()
};
match selection.representation() {
Representation::Html => finalize_negotiated(
web::page_tree(
&self.sql, owner, repo_name, ref_name, &sub_path, actor_name,
),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_tree_markdown(
&self.sql, owner, repo_name, ref_name, &sub_path, &selection,
),
&selection,
),
Representation::Json => unreachable!(),
}
}
(Method::Get, "blob") => {
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
let ref_name = parts.get(3).unwrap_or(&"main");
let sub_path = if parts.len() > 4 {
parts[4..].join("/")
} else {
String::new()
};
match selection.representation() {
Representation::Html => finalize_negotiated(
web::page_blob(
&self.sql, owner, repo_name, ref_name, &sub_path, actor_name,
),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_blob_markdown(
&self.sql, owner, repo_name, ref_name, &sub_path, &selection,
),
&selection,
),
Representation::Json => unreachable!(),
}
}
(Method::Get, "search-ui") => {
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Html => finalize_negotiated(
web::page_search(&self.sql, owner, repo_name, &url, actor_name),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_search_markdown(&self.sql, owner, repo_name, &url, &selection),
&selection,
),
Representation::Json => unreachable!(),
}
}
(Method::Get, "settings") => {
if let Some(resp) = check_write_access(&actor, owner) {
return resp;
}
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Html => finalize_negotiated(
web::page_settings(&self.sql, owner, repo_name, actor_name),
&selection,
),
Representation::Markdown => finalize_negotiated(
web::page_settings_markdown(&self.sql, owner, repo_name, &selection),
&selection,
),
Representation::Json => unreachable!(),
}
}
(Method::Post, "settings") => {
if let Some(resp) = check_write_access(&actor, owner) {
return resp;
}
let sub = parts.get(3).copied().unwrap_or("");
self.handle_settings_action(owner, repo_name, sub, &url, req)
.await
}
(Method::Get, "raw") => {
let ref_name = parts.get(3).unwrap_or(&"main");
let sub_path = if parts.len() > 4 {
parts[4..].join("/")
} else {
String::new()
};
web::serve_raw(&self.sql, ref_name, &sub_path)
}
// -- Issues --
(Method::Get, "issues") => {
let sub = parts.get(3).copied().unwrap_or("");
if sub == "new" && actor.is_none() {
return unauthorized_401();
}
let issue_number = if sub.is_empty() || sub == "new" {
None
} else {
match sub.parse::<i64>() {
Ok(num) => Some(num),
Err(_) => return Response::error("Not Found", 404),
}
};
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Html => match (sub, issue_number) {
("", _) => finalize_negotiated(
issues_web::page_issues_list(
&self.sql, owner, repo_name, &url, actor_name,
),
&selection,
),
("new", _) => finalize_negotiated(
issues_web::page_new_issue(&self.sql, owner, repo_name, actor_name),
&selection,
),
(_, Some(num)) => finalize_negotiated(
issues_web::page_issue_detail(
&self.sql, owner, repo_name, num, actor_name,
),
&selection,
),
_ => Response::error("Not Found", 404),
},
Representation::Markdown => match (sub, issue_number) {
("", _) => finalize_negotiated(
issues_web::page_issues_list_markdown(
&self.sql, owner, repo_name, &url, actor_name, &selection,
),
&selection,
),
("new", _) => finalize_negotiated(
issues_web::page_new_issue_markdown(
&self.sql, owner, repo_name, actor_name, &selection,
),
&selection,
),
(_, Some(num)) => finalize_negotiated(
issues_web::page_issue_detail_markdown(
&self.sql, owner, repo_name, num, actor_name, &selection,
),
&selection,
),
_ => Response::error("Not Found", 404),
},
Representation::Json => unreachable!(),
}
}
(Method::Post, "issues") => {
if actor.is_none() {
return unauthorized_401();
}
let sub3 = parts.get(3).copied().unwrap_or("");
let sub4 = parts.get(4).copied().unwrap_or("");
let aname = actor_name.unwrap_or("");
self.handle_issue_action(owner, repo_name, sub3, sub4, "issues", aname, &url, req)
.await
}
// -- Pull requests --
(Method::Get, "pulls") => {
let sub = parts.get(3).copied().unwrap_or("");
if sub == "new" && actor.is_none() {
return unauthorized_401();
}
let pull_number = if sub.is_empty() || sub == "new" {
None
} else {
match sub.parse::<i64>() {
Ok(num) => Some(num),
Err(_) => return Response::error("Not Found", 404),
}
};
let selection = match negotiate_or_response(
&req,
&[Representation::Html, Representation::Markdown],
Representation::Html,
) {
Ok(selection) => selection,
Err(resp) => return resp,
};
match selection.representation() {
Representation::Html => match (sub, pull_number) {
("", _) => finalize_negotiated(
issues_web::page_pulls_list(
&self.sql, owner, repo_name, &url, actor_name,
),
&selection,
),
("new", _) => finalize_negotiated(
issues_web::page_new_pull(
&self.sql, owner, repo_name, &url, actor_name,
),
&selection,
),
(_, Some(num)) => finalize_negotiated(
issues_web::page_issue_detail(
&self.sql, owner, repo_name, num, actor_name,
),
&selection,
),
_ => Response::error("Not Found", 404),
},
Representation::Markdown => match (sub, pull_number) {
("", _) => finalize_negotiated(
issues_web::page_pulls_list_markdown(
&self.sql, owner, repo_name, &url, actor_name, &selection,
),
&selection,
),
("new", _) => finalize_negotiated(
issues_web::page_new_pull_markdown(
&self.sql, owner, repo_name, &url, actor_name, &selection,
),
&selection,
),
(_, Some(num)) => finalize_negotiated(
issues_web::page_issue_detail_markdown(
&self.sql, owner, repo_name, num, actor_name, &selection,
),
&selection,
),
_ => Response::error("Not Found", 404),
},
Representation::Json => unreachable!(),
}
}
(Method::Post, "pulls") => {
if actor.is_none() {
return unauthorized_401();
}
let sub3 = parts.get(3).copied().unwrap_or("");
let sub4 = parts.get(4).copied().unwrap_or("");
let aname = actor_name.unwrap_or("");
self.handle_issue_action(owner, repo_name, sub3, sub4, "pulls", aname, &url, req)
.await
}
// -- Admin endpoints (owner only) --
(Method::Put, "admin") => {
if let Some(resp) = check_write_access(&actor, owner) {
return resp;
}
let sub = parts.get(3).unwrap_or(&"");
match *sub {
"set-ref" => {
let name = url
.query_pairs()
.find(|(k, _)| k == "name")
.map(|(_, v)| v.to_string());
let hash = url
.query_pairs()
.find(|(k, _)| k == "hash")
.map(|(_, v)| v.to_string());
match (name, hash) {
(Some(n), Some(h)) => {
self.sql.exec(
"INSERT INTO refs (name, commit_hash) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET commit_hash = ?",
vec![
SqlStorageValue::from(n.clone()),
SqlStorageValue::from(h.clone()),
SqlStorageValue::from(h.clone()),
],
)?;
Response::ok(format!("{} -> {}", n, h))
}
_ => Response::ok("need ?name=refs/heads/main&hash=abc123"),
}
}
"config" => {
let key = url
.query_pairs()
.find(|(k, _)| k == "key")
.map(|(_, v)| v.to_string());
let value = url
.query_pairs()
.find(|(k, _)| k == "value")
.map(|(_, v)| v.to_string());
match (key, value) {
(Some(k), Some(v)) => {
store::set_config(&self.sql, &k, &v)?;
Response::ok(format!("{} = {}", k, v))
}
(Some(k), None) => {
let v = store::get_config(&self.sql, &k)?;
Response::ok(v.unwrap_or_else(|| "(not set)".to_string()))
}
_ => Response::ok("need ?key=name[&value=val]"),
}
}
"rebuild-fts" => {
let default_ref = store::get_config(&self.sql, "default_branch")?
.unwrap_or_else(|| "refs/heads/main".to_string());
#[derive(serde::Deserialize)]
struct RefRow {
commit_hash: String,
}
let rows: Vec<RefRow> = self
.sql
.exec(
"SELECT commit_hash FROM refs WHERE name = ?",
vec![SqlStorageValue::from(default_ref)],
)?
.to_array()?;
if let Some(row) = rows.first() {
store::rebuild_fts_index(&self.sql, &row.commit_hash)?;
Response::ok("fts rebuilt")
} else {
Response::ok("no default branch ref found")
}
}
"rebuild-graph" => {
// Bulk rebuild commit graph using INSERT...SELECT per level.
// ~14 SQL calls for any repo size.
self.sql.exec("DELETE FROM commit_graph", None)?;
// Level 0: direct first-parent
self.sql.exec(
"INSERT INTO commit_graph (commit_hash, level, ancestor_hash)
SELECT cp.commit_hash, 0, cp.parent_hash
FROM commit_parents cp WHERE cp.ordinal = 0",
None,
)?;
let mut level: i64 = 1;
loop {
let prev = level - 1;
let result = self.sql.exec(
&format!(
"INSERT INTO commit_graph (commit_hash, level, ancestor_hash)
SELECT cg.commit_hash, {}, cg2.ancestor_hash
FROM commit_graph cg
JOIN commit_graph cg2
ON cg2.commit_hash = cg.ancestor_hash AND cg2.level = {}
WHERE cg.level = {}",
level, prev, prev
),
None,
)?;
if result.rows_written() == 0 {
break;
}
level += 1;
}
Response::ok(format!("commit graph rebuilt ({} levels)", level))
}
"rebuild-fts-commits" => {
// Bulk rebuild fts_commits from all commits
self.sql.exec("DELETE FROM fts_commits", None)?;
self.sql.exec(
"INSERT INTO fts_commits (hash, message, author)
SELECT hash, message, author FROM commits",
None,
)?;
#[derive(serde::Deserialize)]
struct Count {
n: i64,
}
let rows: Vec<Count> = self
.sql
.exec("SELECT COUNT(*) AS n FROM fts_commits", None)?
.to_array()?;
let n = rows.first().map(|r| r.n).unwrap_or(0);
Response::ok(format!("fts_commits rebuilt ({} entries)", n))
}
_ => Response::error("unknown admin action", 404),
}
}
_ => Response::error("Not Found", 404),
}
}
}
// ---------------------------------------------------------------------------
// Git protocol helpers
// ---------------------------------------------------------------------------
impl Repository {
/// Handle POST /:owner/:repo/{issues,pulls}/:sub3/:sub4
async fn handle_issue_action(
&self,
owner: &str,
repo_name: &str,
sub3: &str, // "" | "new" | "<number>"
sub4: &str, // "" | "comment" | "close" | "reopen" | "merge"
kind_url: &str, // "issues" | "pulls"
actor_name: &str, // already validated non-empty by caller
req_url: &Url, // for building absolute redirect URLs
mut req: Request,
) -> Result<Response> {
let kind = if kind_url == "pulls" { "pr" } else { "issue" };
// POST /{issues|pulls} → create
if sub3.is_empty() {
let body = req.text().await?;
let form = issues::parse_form(&body);
let title = form
.get("title")
.map(|s| s.trim().to_string())
.unwrap_or_default();
let body_text = form.get("body").cloned().unwrap_or_default();
if title.is_empty() {
return Response::error("title is required", 400);
}
if kind == "pr" {
let source = form.get("source").cloned().unwrap_or_default();
let target = form.get("target").cloned().unwrap_or_default();
if source.is_empty() || target.is_empty() {
return Response::error("source and target branches are required", 400);
}
if source == target {
return Response::error("source and target branches must differ", 400);
}
let source_ref = format!("refs/heads/{}", source);
let source_hash = match api::resolve_ref(&self.sql, &source_ref)? {
Some(h) => Some(h),
None => return Response::error("source branch not found", 404),
};
let number = issues::create_issue(
&self.sql,
kind,
&title,
&body_text,
actor_name,
actor_name,
Some(&source),
Some(&target),
source_hash.as_deref(),
)?;
return make_redirect(
req_url,
&format!("/{}/{}/{}/{}", owner, repo_name, kind_url, number),
);
} else {
let number = issues::create_issue(
&self.sql, kind, &title, &body_text, actor_name, actor_name, None, None, None,
)?;
return make_redirect(
req_url,
&format!("/{}/{}/{}/{}", owner, repo_name, kind_url, number),
);
}
}
// POST /{issues|pulls}/:n/...
let number: i64 = match sub3.parse() {
Ok(n) => n,
Err(_) => return Response::error("Not Found", 404),
};
match sub4 {
"comment" => {
let body = req.text().await?;
let form = issues::parse_form(&body);
let comment_body = form.get("body").cloned().unwrap_or_default();
let issue = issues::get_issue(&self.sql, number)?
.ok_or_else(|| Error::RustError("not found".into()))?;
issues::create_comment(&self.sql, issue.id, &comment_body, actor_name, actor_name)?;
}
"close" => {
issues::set_issue_state(&self.sql, number, "closed", actor_name, owner)?;
}
"reopen" => {
issues::set_issue_state(&self.sql, number, "open", actor_name, owner)?;
}
"merge" => {
// Only repo owner can merge
if actor_name != owner {
return Response::error("Forbidden: only the repo owner can merge", 403);
}
let target_branch =
issues::get_issue(&self.sql, number)?.and_then(|issue| issue.target_branch);
match issues::merge_pr(&self.sql, number, actor_name) {
Ok(merge_hash) => {
if let Some(target_branch) = target_branch {
if let Some(default_ref) =
store::get_config(&self.sql, "default_branch")?
{
if default_ref == format!("refs/heads/{}", target_branch) {
let _ = store::rebuild_fts_index(&self.sql, &merge_hash);
}
}
}
}
Err(e) => return Response::error(&e.to_string(), 409),
}
}
_ => return Response::error("Not Found", 404),
}
make_redirect(
req_url,
&format!("/{}/{}/{}/{}", owner, repo_name, kind_url, number),
)
}
/// Handle POST /:owner/:repo/settings/:action — all owner-only mutations.
async fn handle_settings_action(
&self,
owner: &str,
repo_name: &str,
action: &str,
req_url: &Url,
mut req: Request,
) -> Result<Response> {
let settings_path = format!("/{}/{}/settings", owner, repo_name);
let back = || -> Result<Response> { make_redirect(req_url, &settings_path) };
match action {
"rebuild-graph" => {
self.sql.exec("DELETE FROM commit_graph", None)?;
self.sql.exec(
"INSERT INTO commit_graph (commit_hash, level, ancestor_hash)
SELECT cp.commit_hash, 0, cp.parent_hash
FROM commit_parents cp WHERE cp.ordinal = 0",
None,
)?;
let mut level: i64 = 1;
loop {
let prev = level - 1;
let result = self.sql.exec(
&format!(
"INSERT INTO commit_graph (commit_hash, level, ancestor_hash)
SELECT cg.commit_hash, {level}, cg2.ancestor_hash
FROM commit_graph cg
JOIN commit_graph cg2
ON cg2.commit_hash = cg.ancestor_hash AND cg2.level = {prev}
WHERE cg.level = {prev}",
level = level,
prev = prev,
),
None,
)?;
if result.rows_written() == 0 {
break;
}
level += 1;
}
back()
}
"rebuild-fts-commits" => {
self.sql.exec("DELETE FROM fts_commits", None)?;
self.sql.exec(
"INSERT INTO fts_commits (hash, message, author)
SELECT hash, message, author FROM commits",
None,
)?;
back()
}
"rebuild-fts" => {
let default_ref = store::get_config(&self.sql, "default_branch")?
.unwrap_or_else(|| "refs/heads/main".to_string());
#[derive(serde::Deserialize)]
struct RefRow {
commit_hash: String,
}
let rows: Vec<RefRow> = self
.sql
.exec(
"SELECT commit_hash FROM refs WHERE name = ?",
vec![SqlStorageValue::from(default_ref)],
)?
.to_array()?;
if let Some(row) = rows.first() {
store::rebuild_fts_index(&self.sql, &row.commit_hash)?;
}
back()
}
"default-branch" => {
let body = req.text().await?;
let branch = body
.split('&')
.find_map(|pair| {
let mut kv = pair.splitn(2, '=');
if kv.next() == Some("branch") {
kv.next().map(|v| v.replace('+', " "))
} else {
None
}
})
.unwrap_or_default();
let branch = branch.trim();
if !branch.is_empty() {
store::set_config(&self.sql, "default_branch", branch)?;
}
back()
}
"delete" => {
let body = req.text().await?;
let confirm_val = body
.split('&')
.find_map(|pair| {
let mut kv = pair.splitn(2, '=');
if kv.next() == Some("confirm") {
kv.next().map(|v| {
// minimal URL decode for / (%2F) and spaces
v.replace('+', " ").replace("%2F", "/").replace("%2f", "/")
})
} else {
None
}
})
.unwrap_or_default();
let expected = format!("{}/{}", owner, repo_name);
if confirm_val.trim() == expected {
self.state.storage().delete_all().await?;
make_redirect(req_url, &format!("/{}/", owner))
} else {
// Wrong confirmation — bounce back to settings
back()
}
}
_ => Response::error("Not Found", 404),
}
}
/// Ref advertisement for both receive-pack and upload-pack.
/// Returns current refs in pkt-line format so git knows what we have.
fn advertise_refs(&self, service: &str) -> Result<Response> {
let content_type = format!("application/x-{}-advertisement", service);
// Collect current refs
#[derive(serde::Deserialize)]
struct RefRow {
name: String,
commit_hash: String,
}
let refs: Vec<RefRow> = self
.sql
.exec("SELECT name, commit_hash FROM refs", None)?
.to_array()?;
let mut body = Vec::new();
// Service announcement
let svc_line = format!("# service={}\n", service);
pkt_line(&mut body, &svc_line);
body.extend_from_slice(b"0000"); // flush
// Build capabilities, including symref for HEAD.
// upload-pack and receive-pack speak different capability sets.
let default_branch = store::get_config(&self.sql, "default_branch")?
.unwrap_or_else(|| "refs/heads/main".to_string());
let caps = match service {
"git-upload-pack" => format!(
"multi_ack_detailed no-done ofs-delta side-band-64k no-progress symref=HEAD:{}",
default_branch
),
_ => format!(
"report-status delete-refs ofs-delta side-band-64k quiet symref=HEAD:{}",
default_branch
),
};
if refs.is_empty() {
// Empty repo: advertise zero-id with capabilities
let line = format!(
"0000000000000000000000000000000000000000 capabilities^{{}}\0{}\n",
caps
);
pkt_line(&mut body, &line);
} else {
// Find the default branch's commit for HEAD
let head_hash = refs
.iter()
.find(|r| r.name == default_branch)
.map(|r| r.commit_hash.clone());
let mut first = true;
// Advertise HEAD first (so git clone checks out the right branch)
if let Some(ref hh) = head_hash {
let line = format!("{} HEAD\0{}\n", hh, caps);
pkt_line(&mut body, &line);
first = false;
}
for r in refs.iter() {
let line = if first {
first = false;
format!("{} {}\0{}\n", r.commit_hash, r.name, caps)
} else {
format!("{} {}\n", r.commit_hash, r.name)
};
pkt_line(&mut body, &line);
}
}
body.extend_from_slice(b"0000"); // flush
let mut resp = Response::from_bytes(body)?;
resp.headers_mut().set("Content-Type", &content_type)?;
resp.headers_mut().set("Cache-Control", "no-cache")?;
Ok(resp)
}
}
// ---------------------------------------------------------------------------
// Pkt-line encoding
// ---------------------------------------------------------------------------
/// Append a pkt-line encoded string to the buffer.
/// Pkt-line format: 4 hex digits for total length (including the 4 digits),
/// followed by the payload.
fn pkt_line(buf: &mut Vec<u8>, data: &str) {
let len = 4 + data.len();
buf.extend_from_slice(format!("{:04x}", len).as_bytes());
buf.extend_from_slice(data.as_bytes());
}
/// Check if a string is a 40-character hex SHA-1 hash.
/// Used to distinguish API calls (by hash) from web UI calls (by ref + path).
fn is_hex40(s: &str) -> bool {
s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// List repos registered in the REGISTRY KV for the given owner.
/// Keys are stored as "repo:{owner}/{repo}".
/// Returns an empty list if the KV binding is unavailable or the list fails.
async fn list_repos(env: &Env, owner: &str) -> Vec<String> {
let prefix = format!("repo:{}/", owner);
let kv = match env.kv("REGISTRY") {
Ok(kv) => kv,
Err(_) => return vec![],
};
match kv.list().prefix(prefix.clone()).execute().await {
Ok(result) => result
.keys
.into_iter()
.map(|k| k.name[prefix.len()..].to_string())
.collect(),
Err(_) => vec![],
}
}