a73x

src/server/http/repo/history.rs

Ref:   Size: 5.9 KiB   History

use std::sync::Arc;

use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};

use super::{
    collab_counts, head_branch_name, list_branches, not_found, open_repo, AppState, OverviewCommit,
};

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "history.html")]
pub struct HistoryTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub ref_name: String,
    pub branches: Vec<String>,
    pub file_path: String,
    pub commits: Vec<OverviewCommit>,
}

/// The `(mode, blob oid)` a commit's tree holds at `path`, or `None` if the
/// path is absent. The mode is part of the comparison because a chmod
/// 100644→100755 keeps the same oid — `git log -- <path>` counts that as a
/// change, and so must we.
fn entry_at(commit: &git2::Commit, path: &str) -> Option<(i32, git2::Oid)> {
    let tree = commit.tree().ok()?;
    let entry = tree.get_path(std::path::Path::new(path)).ok()?;
    // A directory at the path is not a file history entry; skip it.
    if entry.kind() == Some(git2::ObjectType::Tree) {
        return None;
    }
    Some((entry.filemode(), entry.id()))
}

/// Commits reachable from `head` in which the `(mode, oid)` at `path` differs
/// from the entry in *every* parent. This is git's default TREESAME
/// simplification: a merge whose entry matches any parent is suppressed (git
/// follows that parent instead), which keeps a clean non-fast-forward merge
/// from listing both itself and the source-branch commit. A root commit (no
/// parents) is included when it introduces the path.
///
/// This is `git log -- <path>` without rename following: a move shows up as a
/// deletion and an addition, which is the honest answer a reader can
/// re-derive from the diffs it links to.
fn history_for_path(
    repo: &git2::Repository,
    head: &git2::Commit,
    path: &str,
    limit: usize,
) -> Vec<OverviewCommit> {
    let mut revwalk = match repo.revwalk() {
        Ok(rw) => rw,
        Err(_) => return Vec::new(),
    };
    if revwalk.push(head.id()).is_err() {
        return Vec::new();
    }
    // Children-before-parents (newest-first), stable across commits that
    // share a timestamp. Verified against a repo whose three file-touching
    // commits all landed in the same second: `TIME` reordered them wrongly,
    // while `TOPOLOGICAL` and the default (`NONE`) both held. `TOPOLOGICAL` is
    // chosen over the default because it *guarantees* the order rather than
    // happening to produce it.
    let _ = revwalk.set_sorting(git2::Sort::TOPOLOGICAL);

    revwalk
        .filter_map(|oid| {
            let oid = oid.ok()?;
            let commit = repo.find_commit(oid).ok()?;
            let current = entry_at(&commit, path);

            // A commit is a history entry iff its entry at `path` differs from
            // *every* parent — git's TREESAME simplification. A merge whose
            // entry matches any parent is suppressed (comparing only parent 0
            // would list a clean merge alongside the source-branch commit that
            // git log drops it for). A root commit (no parents) is included
            // only when it introduces the path: "differs from all parents" is
            // vacuously true for a parentless commit, so a root that lacks the
            // file must be rejected explicitly.
            let parents: Vec<git2::Commit> = (0..commit.parent_count())
                .filter_map(|i| commit.parent(i).ok())
                .collect();

            let is_entry = if parents.is_empty() {
                current.is_some()
            } else {
                parents
                    .iter()
                    .all(|parent| entry_at(parent, path) != current)
            };
            if !is_entry {
                return None;
            }

            let id = oid.to_string();
            let short_id = id[..8.min(id.len())].to_string();
            let summary = commit.summary().unwrap_or("").to_string();
            let author = commit.author().name().unwrap_or("").to_string();
            let secs = commit.time().seconds();
            let date = chrono::TimeZone::timestamp_opt(&chrono::Utc, secs, 0)
                .single()
                .map(|dt| dt.format("%Y-%m-%d").to_string())
                .unwrap_or_default();
            Some(OverviewCommit {
                id,
                short_id,
                summary,
                author,
                date,
            })
        })
        .take(limit)
        .collect()
}

pub async fn history(
    Path((repo_name, rest)): Path<(String, String)>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (_entry, repo) = match open_repo(&state, &repo_name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };

    let (open_patches, open_issues) = collab_counts(&repo);
    let branches = list_branches(&repo);
    let (ref_name, file_path) = super::tree::split_ref_path(&rest);
    let ref_name = if ref_name == "HEAD" {
        head_branch_name(&repo)
    } else {
        ref_name
    };

    // Resolve the ref so an unknown ref is a 404, but do NOT require the
    // path to exist at HEAD: a deleted file's history is a legitimate query,
    // and the walk will simply be empty for a path that was never tracked.
    let obj = match repo.revparse_single(&ref_name) {
        Ok(o) => o,
        Err(_) => return not_found(&state, format!("Ref '{}' not found.", ref_name)),
    };
    let commit = match obj.peel_to_commit() {
        Ok(c) => c,
        Err(_) => return not_found(&state, "Could not resolve ref to a commit."),
    };

    let commits = history_for_path(&repo, &commit, &file_path, 200);

    HistoryTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "tree".to_string(),
        open_patches,
        open_issues,
        ref_name,
        branches,
        file_path,
        commits,
    }
    .into_response()
}