src/server/http/repo_list.rs
Ref: Size: 6.1 KiB
use std::sync::Arc;
use axum::extract::State;
use axum::response::IntoResponse;
use super::AppState;
#[derive(Debug)]
pub struct RepoListItem {
pub name: String,
pub description: String,
pub last_commit: String,
}
#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "repo_list.html")]
pub struct RepoListTemplate {
pub site_title: String,
pub repos: Vec<RepoListItem>,
}
pub async fn handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let repos = build_repo_list(&state);
RepoListTemplate {
site_title: state.site_title.clone(),
repos,
}
}
fn build_repo_list(state: &AppState) -> Vec<RepoListItem> {
let entries = match crate::repos::discover(&state.repos_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
entries
.into_iter()
.map(|entry| {
let repo = crate::repos::open(&entry).ok();
let description = resolve_description(&entry, repo.as_ref());
let last_commit = repo.as_ref().map(read_last_commit).unwrap_or_default();
RepoListItem {
name: entry.name,
description,
last_commit,
}
})
.collect()
}
fn normalize_description(description: &str) -> Option<String> {
let description = description.trim();
if description.is_empty() || description.starts_with("Unnamed repository") {
None
} else {
Some(description.to_string())
}
}
fn resolve_display_commit(repo: &git2::Repository) -> Option<git2::Commit<'_>> {
if let Ok(head) = repo.head() {
if let Ok(commit) = head.peel_to_commit() {
return Some(commit);
}
}
let branches = repo.branches(Some(git2::BranchType::Local)).ok()?;
for branch in branches.filter_map(Result::ok) {
let (branch, _) = branch;
if let Ok(commit) = branch.into_reference().peel_to_commit() {
return Some(commit);
}
}
None
}
fn read_tracked_description(repo: &git2::Repository) -> Option<String> {
let commit = resolve_display_commit(repo)?;
let tree = commit.tree().ok()?;
let entry = tree.get_path(std::path::Path::new(".description")).ok()?;
let blob = entry.to_object(repo).ok()?.into_blob().ok()?;
let description = std::str::from_utf8(blob.content()).ok()?;
normalize_description(description)
}
fn read_local_description(entry: &crate::repos::RepoEntry) -> Option<String> {
let desc_path = if entry.bare {
entry.path.join("description")
} else {
entry.path.join(".git").join("description")
};
let description = std::fs::read_to_string(&desc_path).ok()?;
normalize_description(&description)
}
fn resolve_description(entry: &crate::repos::RepoEntry, repo: Option<&git2::Repository>) -> String {
repo.and_then(read_tracked_description)
.or_else(|| read_local_description(entry))
.unwrap_or_default()
}
#[cfg(test)]
fn read_description(entry: &crate::repos::RepoEntry) -> String {
let repo = crate::repos::open(entry).ok();
resolve_description(entry, repo.as_ref())
}
fn read_last_commit(repo: &git2::Repository) -> String {
let commit = match resolve_display_commit(repo) {
Some(commit) => commit,
None => return String::new(),
};
let time = commit.time();
let secs = time.seconds();
// Format as YYYY-MM-DD using chrono
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{read_description, read_local_description};
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn git(cwd: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("git command should run");
assert!(
output.status.success(),
"git {:?} failed: stdout={} stderr={}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn make_non_bare_entry(path: &Path) -> crate::repos::RepoEntry {
crate::repos::RepoEntry {
name: "repo".to_string(),
path: path.to_path_buf(),
bare: false,
}
}
fn make_bare_entry(path: &Path) -> crate::repos::RepoEntry {
crate::repos::RepoEntry {
name: "repo".to_string(),
path: path.to_path_buf(),
bare: true,
}
}
#[test]
fn read_description_prefers_tracked_description_for_bare_repo() {
let tmp = TempDir::new().unwrap();
let bare_repo = tmp.path().join("repo.git");
let work_repo = tmp.path().join("work");
git(tmp.path(), &["init", "--bare", "repo.git"]);
git(tmp.path(), &["clone", bare_repo.to_str().unwrap(), "work"]);
git(&work_repo, &["config", "user.name", "Test User"]);
git(&work_repo, &["config", "user.email", "test@example.com"]);
git(&work_repo, &["checkout", "-b", "main"]);
std::fs::write(work_repo.join(".description"), "Tracked description\n").unwrap();
git(&work_repo, &["add", ".description"]);
git(&work_repo, &["commit", "-m", "Add tracked description"]);
git(&work_repo, &["push", "-u", "origin", "main"]);
std::fs::write(bare_repo.join("description"), "Local bare description\n").unwrap();
let entry = make_bare_entry(&bare_repo);
assert_eq!(read_description(&entry), "Tracked description");
}
#[test]
fn read_local_description_falls_back_to_git_metadata_file() {
let tmp = TempDir::new().unwrap();
let repo_path = tmp.path().join("repo");
git(tmp.path(), &["init", "repo"]);
std::fs::write(repo_path.join(".git").join("description"), "Local repo description\n").unwrap();
let entry = make_non_bare_entry(&repo_path);
assert_eq!(read_local_description(&entry), Some("Local repo description".to_string()));
assert_eq!(read_description(&entry), "Local repo description");
}
}