yuki / okibi
焚き火の仲間だけの自前Gitホスティング(このサイト自身)
use crate::auth::User;
use crate::config::Config;
use crate::db::Db;
use crate::util::now;
use std::path::PathBuf;
use std::process::Command;
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct Repo {
pub id: i64,
pub owner_id: i64,
pub name: String,
pub visibility: String,
pub default_branch: String,
pub description: String,
}
impl Repo {
pub fn is_public(&self) -> bool {
self.visibility == "public"
}
}
/// On-disk path of a repo's bare git dir.
pub fn repo_path(cfg: &Config, owner_handle: &str, name: &str) -> PathBuf {
cfg.repos_dir.join(owner_handle).join(format!("{name}.git"))
}
/// Insert a repo row (no disk side-effects).
pub async fn insert_repo_meta(
db: &Db,
owner_id: i64,
name: &str,
visibility: &str,
description: &str,
default_branch: &str,
) -> anyhow::Result<Repo> {
let visibility = if visibility == "public" { "public" } else { "private" };
sqlx::query(
"INSERT INTO repos (owner_id, name, visibility, default_branch, description, created_at)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(owner_id)
.bind(name)
.bind(visibility)
.bind(default_branch)
.bind(description)
.bind(now())
.execute(db)
.await?;
get_repo(db, owner_id, name)
.await
.ok_or_else(|| anyhow::anyhow!("repo vanished after insert"))
}
/// Create repo metadata + initialise a bare git repo on disk.
pub async fn create_repo(
db: &Db,
cfg: &Config,
owner: &User,
name: &str,
visibility: &str,
description: &str,
) -> anyhow::Result<Repo> {
let repo = insert_repo_meta(db, owner.id, name, visibility, description, "main").await?;
let path = repo_path(cfg, &owner.handle, name);
std::fs::create_dir_all(&path)?;
let path_str = path.to_string_lossy().to_string();
run_git(cfg, &["init", "--bare", "--initial-branch=main", &path_str])?;
// Allow smart-HTTP push/pull for this repo.
run_git_in(cfg, &path, &["config", "http.receivepack", "true"])?;
run_git_in(cfg, &path, &["config", "http.uploadpack", "true"])?;
Ok(repo)
}
/// Server-side import: the host mirror-clones `git_url` into the repo's bare
/// path, then scrubs any embedded credentials. Blocking (run in spawn_blocking).
pub fn import_clone(cfg: &Config, owner_handle: &str, name: &str, git_url: &str) -> anyhow::Result<()> {
let path = repo_path(cfg, owner_handle, name);
if path.exists() {
anyhow::bail!("path already exists");
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let out = Command::new(&cfg.git_bin)
.env("GIT_TERMINAL_PROMPT", "0")
.args(["clone", "--mirror", "--quiet", git_url])
.arg(&path)
.output()?;
if !out.status.success() {
let _ = std::fs::remove_dir_all(&path);
anyhow::bail!("clone failed: {}", scrub(&String::from_utf8_lossy(&out.stderr)));
}
// Drop the origin remote so no token persists in the bare repo's config.
let _ = run_git_in(cfg, &path, &["remote", "remove", "origin"]);
run_git_in(cfg, &path, &["config", "http.receivepack", "true"])?;
run_git_in(cfg, &path, &["config", "http.uploadpack", "true"])?;
Ok(())
}
/// Detect the default branch of an imported bare repo (HEAD target).
pub fn detect_head(cfg: &Config, owner_handle: &str, name: &str) -> Option<String> {
let path = repo_path(cfg, owner_handle, name);
let out = run_git_in(cfg, &path, &["symbolic-ref", "--short", "HEAD"]).ok()?;
let b = out.trim().to_string();
if b.is_empty() {
None
} else {
Some(b)
}
}
/// Redact `scheme://user:pass@host` credentials from each whitespace token
/// before logging, so tokens never reach the logs.
fn scrub(s: &str) -> String {
s.split_whitespace()
.map(|tok| {
if let Some(scheme_end) = tok.find("://") {
let after = &tok[scheme_end + 3..];
if let Some(at) = after.find('@') {
return format!("{}://***@{}", &tok[..scheme_end], &after[at + 1..]);
}
}
tok.to_string()
})
.collect::<Vec<_>>()
.join(" ")
}
pub async fn get_repo(db: &Db, owner_id: i64, name: &str) -> Option<Repo> {
sqlx::query_as::<_, Repo>(
"SELECT id, owner_id, name, visibility, default_branch, description
FROM repos WHERE owner_id = ? AND name = ?",
)
.bind(owner_id)
.bind(name)
.fetch_optional(db)
.await
.ok()
.flatten()
}
pub async fn list_repos_for_owner(db: &Db, owner_id: i64) -> Vec<Repo> {
sqlx::query_as::<_, Repo>(
"SELECT id, owner_id, name, visibility, default_branch, description
FROM repos WHERE owner_id = ? ORDER BY name",
)
.bind(owner_id)
.fetch_all(db)
.await
.unwrap_or_default()
}
/// Repos visible to `viewer` (public ones + viewer's own). Newest first.
pub async fn list_visible(db: &Db, viewer: Option<&User>) -> Vec<(Repo, String)> {
let rows = sqlx::query_as::<_, (i64, i64, String, String, String, String, String)>(
"SELECT r.id, r.owner_id, r.name, r.visibility, r.default_branch, r.description, u.handle
FROM repos r JOIN users u ON u.id = r.owner_id
ORDER BY r.created_at DESC",
)
.fetch_all(db)
.await
.unwrap_or_default();
rows.into_iter()
.map(|(id, owner_id, name, visibility, default_branch, description, handle)| {
(
Repo { id, owner_id, name, visibility, default_branch, description },
handle,
)
})
.filter(|(r, _)| {
r.is_public() || viewer.map(|v| v.id == r.owner_id).unwrap_or(false)
})
.collect()
}
/// Does the repo have any commits yet?
pub fn is_empty(cfg: &Config, owner_handle: &str, name: &str) -> bool {
let path = repo_path(cfg, owner_handle, name);
run_git_in(cfg, &path, &["rev-parse", "--verify", "HEAD"]).is_err()
}
/// `git ls-tree` a path at HEAD (or a ref). Returns (mode, type, name) rows.
pub fn ls_tree(cfg: &Config, owner_handle: &str, name: &str, rev: &str, subpath: &str) -> Vec<(String, String, String)> {
let path = repo_path(cfg, owner_handle, name);
let spec = if subpath.is_empty() {
rev.to_string()
} else {
format!("{rev}:{subpath}")
};
let out = match run_git_in(cfg, &path, &["ls-tree", "--long", &spec]) {
Ok(o) => o,
Err(_) => return vec![],
};
let mut entries = vec![];
for line in out.lines() {
// <mode> <type> <object> <size>\t<name>
if let Some((meta, fname)) = line.split_once('\t') {
let cols: Vec<&str> = meta.split_whitespace().collect();
if cols.len() >= 2 {
entries.push((cols[0].to_string(), cols[1].to_string(), fname.to_string()));
}
}
}
// dirs first, then files, alpha
entries.sort_by(|a, b| {
let ad = a.1 == "tree";
let bd = b.1 == "tree";
bd.cmp(&ad).then(a.2.cmp(&b.2))
});
entries
}
/// Read a blob's bytes at `rev:path`.
pub fn read_blob(cfg: &Config, owner_handle: &str, name: &str, rev: &str, file: &str) -> Option<Vec<u8>> {
let path = repo_path(cfg, owner_handle, name);
let spec = format!("{rev}:{file}");
let output = Command::new(&cfg.git_bin)
.arg("-C")
.arg(&path)
.args(["cat-file", "blob", &spec])
.output()
.ok()?;
if output.status.success() {
Some(output.stdout)
} else {
None
}
}
/// Latest commit (sha, subject, unix time) for a ref.
pub fn head_commit(cfg: &Config, owner_handle: &str, name: &str, rev: &str) -> Option<(String, String, i64)> {
let path = repo_path(cfg, owner_handle, name);
let out = run_git_in(cfg, &path, &["log", "-1", "--format=%H%x00%s%x00%ct", rev]).ok()?;
let parts: Vec<&str> = out.trim().split('\u{0}').collect();
if parts.len() == 3 {
Some((parts[0].to_string(), parts[1].to_string(), parts[2].parse().unwrap_or(0)))
} else {
None
}
}
fn run_git(cfg: &Config, args: &[&str]) -> anyhow::Result<String> {
let out = Command::new(&cfg.git_bin).args(args).output()?;
if !out.status.success() {
anyhow::bail!("git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
fn run_git_in(cfg: &Config, dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
let out = Command::new(&cfg.git_bin).arg("-C").arg(dir).args(args).output()?;
if !out.status.success() {
anyhow::bail!("git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}