a73x

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");
    }
}