tirbofish/kitgit · diff
blame history
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -0,0 +1,134 @@ +//! Blame and path history helpers (git2). + +use anyhow::{anyhow, Result}; +use git2::{BlameOptions, Commit, Oid, Repository as G2Repo, Sort}; +use std::collections::HashMap; +use std::path::Path; + +use super::repo::{read_blob, resolve_ref, CommitInfo}; + +/// One displayed line in a blame view. +#[derive(Debug, Clone)] +pub struct BlameLine { + pub line_no: usize, + pub content: String, + pub commit_id: String, + pub short_id: String, + pub author: String, + pub time: i64, + pub summary: String, + /// First line of a contiguous blame hunk (same final commit). + pub hunk_start: bool, +} + +/// Commits that introduced a change to `path` (blob OID differs from parent), newest first. +pub fn list_commits_for_path( + repo: &G2Repo, + reference: &str, + path: &str, + limit: usize, +) -> Result<Vec<CommitInfo>> { + let oid = resolve_ref(repo, reference)?; + let mut revwalk = repo.revwalk()?; + revwalk.push(oid)?; + revwalk.set_sorting(Sort::TIME)?; + let mut out = Vec::new(); + for id in revwalk { + let id = id?; + let c = repo.find_commit(id)?; + if !path_changed_in_commit(&c, path)? { + continue; + } + let author = c.author(); + out.push(CommitInfo { + id: id.to_string(), + short_id: id.to_string()[..7.min(id.to_string().len())].to_string(), + message: c.summary().unwrap_or("").to_string(), + author: author.name().unwrap_or("").to_string(), + email: author.email().unwrap_or("").to_string(), + time: author.when().seconds(), + }); + if out.len() >= limit { + break; + } + } + Ok(out) +} + +fn path_changed_in_commit(commit: &Commit, path: &str) -> Result<bool> { + let tree = commit.tree()?; + let new_id = tree.get_path(Path::new(path)).ok().map(|e| e.id()); + if commit.parent_count() == 0 { + return Ok(new_id.is_some()); + } + let parent_tree = commit.parent(0)?.tree()?; + let old_id = parent_tree.get_path(Path::new(path)).ok().map(|e| e.id()); + Ok(old_id != new_id) +} + +/// Per-line blame for a text file at `reference`:`path`. +pub fn blame_file(repo: &G2Repo, reference: &str, path: &str) -> Result<Vec<BlameLine>> { + let (data, binary) = read_blob(repo, reference, path)?; + if binary { + return Err(anyhow!("binary file")); + } + let oid = resolve_ref(repo, reference)?; + let mut opts = BlameOptions::new(); + opts.newest_commit(oid); + let blame = repo.blame_file(Path::new(path), Some(&mut opts))?; + + let text = String::from_utf8_lossy(&data); + let mut lines: Vec<&str> = text.split('\n').collect(); + if text.ends_with('\n') { + lines.pop(); + } + + let mut summaries: HashMap<Oid, String> = HashMap::new(); + let mut out = Vec::with_capacity(lines.len()); + let mut prev_commit: Option<Oid> = None; + + for (i, line) in lines.iter().enumerate() { + let line_no = i + 1; + let Some(hunk) = blame.get_line(line_no) else { + out.push(BlameLine { + line_no, + content: (*line).to_string(), + commit_id: String::new(), + short_id: String::new(), + author: String::new(), + time: 0, + summary: String::new(), + hunk_start: true, + }); + continue; + }; + let commit_oid = hunk.final_commit_id(); + let commit_id = commit_oid.to_string(); + let short_id = commit_id[..7.min(commit_id.len())].to_string(); + let sig = hunk.final_signature(); + let author = sig.name().unwrap_or("").to_string(); + let time = sig.when().seconds(); + let summary = summaries + .entry(commit_oid) + .or_insert_with(|| { + repo.find_commit(commit_oid) + .ok() + .and_then(|c| c.summary().map(|s| s.to_string())) + .unwrap_or_default() + }) + .clone(); + let hunk_start = prev_commit != Some(commit_oid); + prev_commit = Some(commit_oid); + out.push(BlameLine { + line_no, + content: (*line).to_string(), + commit_id, + short_id, + author, + time, + summary, + hunk_start, + }); + } + Ok(out) +} @@ -1,3 +1,4 @@ +pub mod blame; pub mod http; pub mod languages; pub mod lfs; @@ -5,5 +6,6 @@ pub mod repo; pub mod ssh; pub mod verify; +pub use blame::{blame_file, list_commits_for_path}; pub use repo::*; pub use verify::{extract_commit_signature, verify_commit_signature}; @@ -161,6 +161,8 @@ pub fn app_router(state: AppState) -> Router { .route("/{owner}/{repo}/og.png", get(routes::repo_og_image)) .route("/{owner}/{repo}/tree/{*rest}", get(routes::repo_tree)) .route("/{owner}/{repo}/blob/{*rest}", get(routes::repo_blob)) + .route("/{owner}/{repo}/blame/{*rest}", get(routes::repo_blame)) + .route("/{owner}/{repo}/history/{*rest}", get(routes::repo_history)) .route("/{owner}/{repo}/raw/{*rest}", get(routes_extra::repo_raw)) .route("/{owner}/{repo}/star", post(routes_extra::repo_star)) .route("/{owner}/{repo}/watch", post(routes_extra::repo_watch)) @@ -2136,6 +2136,110 @@ pub async fn repo_blob( }) } +fn blame_time_display(secs: i64) -> String { + chrono::DateTime::from_timestamp(secs, 0) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_default() +} + +pub async fn repo_blame( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, rest)): Path<(String, String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .map_err(|_| AppError::not_found())?; + let (branch, path) = split_ref_path(&grepo, &rest); + if path.is_empty() { + return Err(AppError::bad("missing file path")); + } + let binary = match git::read_blob(&grepo, &branch, &path) { + Ok((_, binary)) => binary, + Err(_) => return Err(AppError::not_found()), + }; + let lines = if binary { + Vec::new() + } else { + git::blame_file(&grepo, &branch, &path) + .map_err(|e| AppError::not_found().with_message(e.to_string()))? + .into_iter() + .map(|l| BlameLineView { + line_no: l.line_no, + content: l.content, + commit_id: l.commit_id, + short_id: l.short_id, + author: l.author, + time_display: blame_time_display(l.time), + summary: l.summary, + hunk_start: l.hunk_start, + }) + .collect() + }; + let branches = git::list_branches(&grepo).unwrap_or_default(); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(RepoBlameTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + branches, + branch, + path: path.clone(), + breadcrumbs: breadcrumbs(&path), + lines, + binary, + clone_http, + clone_ssh, + }) +} + +pub async fn repo_history( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, rest)): Path<(String, String, String)>, +) -> AppResult<impl IntoResponse> { + let (repository, owner_user, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo) + .map_err(|_| AppError::not_found())?; + let (branch, path) = split_ref_path(&grepo, &rest); + if path.is_empty() { + return Err(AppError::bad("missing file path")); + } + let raw_commits = git::list_commits_for_path(&grepo, &branch, &path, 100).unwrap_or_default(); + if raw_commits.is_empty() + && git::read_blob(&grepo, &branch, &path).is_err() + && !git::path_is_dir(&grepo, &branch, &path) + { + return Err(AppError::not_found()); + } + let prepared: Vec<_> = raw_commits + .into_iter() + .map(|c| { + let extracted = git::extract_commit_signature(&grepo, &c.id); + (c, extracted) + }) + .collect(); + let commits = commit_views(&state, prepared).await; + let branches = git::list_branches(&grepo).unwrap_or_default(); + let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); + Ok(RepoHistoryTemplate { + viewer, + owner: owner_user, + repo: repository, + access, + branches, + branch, + path: path.clone(), + breadcrumbs: breadcrumbs(&path), + commits, + clone_http, + clone_ssh, + }) +} + #[derive(Deserialize)] pub struct BranchQuery { pub branch: Option<String>, @@ -302,6 +302,50 @@ pub struct RepoBlobTemplate { pub clone_ssh: String, } +pub struct BlameLineView { + pub line_no: usize, + pub content: String, + pub commit_id: String, + pub short_id: String, + pub author: String, + pub time_display: String, + pub summary: String, + pub hunk_start: bool, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_blame.html")] +pub struct RepoBlameTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub branches: Vec<String>, + pub branch: String, + pub path: String, + pub breadcrumbs: Vec<(String, String)>, + pub lines: Vec<BlameLineView>, + pub binary: bool, + pub clone_http: String, + pub clone_ssh: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "repo_history.html")] +pub struct RepoHistoryTemplate { + pub viewer: Option<User>, + pub owner: User, + pub repo: Repository, + pub access: Access, + pub branches: Vec<String>, + pub branch: String, + pub path: String, + pub breadcrumbs: Vec<(String, String)>, + pub commits: Vec<CommitView>, + pub clone_http: String, + pub clone_ssh: String, +} + #[derive(Template, WebTemplate)] #[template(path = "repo_commits.html")] pub struct RepoCommitsTemplate { @@ -647,6 +647,102 @@ a:hover { align-items: center; } +.kg-blame { + border: 1px solid var(--kg-line); + margin-top: var(--kg-space-3); + overflow-x: auto; + font-family: var(--kg-font); + font-size: 0.8125rem; + line-height: 1.5; +} + +.kg-blame__head, +.kg-blame__row { + display: grid; + grid-template-columns: minmax(12rem, 16rem) 3.25rem minmax(0, 1fr); + align-items: stretch; + border-bottom: 1px solid var(--kg-line); +} + +.kg-blame__head { + background: var(--kg-bg); + color: var(--kg-faint); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.kg-blame__head > span { + padding: 0.45rem 0.65rem; +} + +.kg-blame__row:last-child { + border-bottom: 0; +} + +.kg-blame__row--hunk { + border-top: 1px solid var(--kg-line); +} + +.kg-blame__row--hunk:first-of-type { + border-top: 0; +} + +.kg-blame__meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem 0.55rem; + padding: 0.15rem 0.65rem; + background: var(--kg-bg); + border-right: 1px solid var(--kg-line); + min-width: 0; + color: var(--kg-muted, var(--kg-faint)); +} + +.kg-blame__sha { + font-family: inherit; + font-variant-numeric: tabular-nums; +} + +.kg-blame__author { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 7rem; + color: var(--kg-fg); +} + +.kg-blame__date { + font-variant-numeric: tabular-nums; + color: var(--kg-faint); +} + +.kg-blame__num { + margin: 0; + padding: 0.15rem 0.55rem; + text-align: right; + color: var(--kg-faint); + user-select: none; + font-variant-numeric: tabular-nums; + border-right: 1px solid var(--kg-line); + background: var(--kg-bg); +} + +.kg-blame__code { + margin: 0; + padding: 0.15rem 0.75rem; + min-width: 0; + overflow-x: auto; + white-space: pre; + font-family: inherit; + font-size: inherit; + line-height: inherit; + background: transparent; + border: 0; + color: inherit; +} + .kg-danger-zone { margin-top: var(--kg-space-5); padding: var(--kg-space-3); @@ -0,0 +1,66 @@ +{% extends "layout.html" %} + +{% block title %}blame · {{ path }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · blame + </h1> + {% let tab = "code" %} + {% let archive_ref = branch %} + {% include "partials/repo_tabs.html" %} + + <p class="kg-meta kg-breadcrumbs"> + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}">{{ branch }}</a> + {% for (name, full) in breadcrumbs %} + / + {% if loop.last %} + {{ name }} + {% else %} + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ full }}">{{ name }}</a> + {% endif %} + {% endfor %} + </p> + + <div class="kg-blobbar"> + <span class="kg-meta">{{ path }}</span> + <div class="kg-blobbar__actions"> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ path }}">View file</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/history/{{ branch }}/{{ path }}">History</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/raw/{{ branch }}/{{ path }}"> + <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> + Download raw + </a> + </div> + </div> + + {% if binary %} + <p class="kg-muted">Binary file — blame is not available.</p> + {% else if lines.is_empty() %} + <p class="kg-muted">Empty file.</p> + {% else %} + <div class="kg-blame" role="table" aria-label="Blame for {{ path }}"> + <div class="kg-blame__head" role="row"> + <span class="kg-blame__meta" role="columnheader">Commit</span> + <span class="kg-blame__num" role="columnheader">#</span> + <span class="kg-blame__code" role="columnheader">Line</span> + </div> + {% for line in lines %} + <div class="kg-blame__row{% if line.hunk_start %} kg-blame__row--hunk{% endif %}" role="row"> + <div class="kg-blame__meta" role="cell"> + {% if line.hunk_start && !line.commit_id.is_empty() %} + <a class="kg-blame__sha" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ line.commit_id }}" title="{{ line.summary }}">{{ line.short_id }}</a> + <span class="kg-blame__author" title="{{ line.summary }}">{{ line.author }}</span> + <span class="kg-blame__date">{{ line.time_display }}</span> + {% endif %} + </div> + <span class="kg-blame__num" role="cell">{{ line.line_no }}</span> + <pre class="kg-blame__code" role="cell">{{ line.content }}</pre> + </div> + {% endfor %} + </div> + {% endif %} +</section> +{% endblock %} @@ -33,6 +33,8 @@ <div class="kg-blobbar"> <span class="kg-meta">{{ path }}</span> <div class="kg-blobbar__actions"> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/blame/{{ branch }}/{{ path }}">Blame</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/history/{{ branch }}/{{ path }}">History</a> <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/raw/{{ branch }}/{{ path }}"> <span class="kg-icon" style="--icon:url('/static/icons/download.svg')"></span> Download raw @@ -0,0 +1,67 @@ +{% extends "layout.html" %} + +{% block title %}history · {{ path }} · {{ owner.username }}/{{ repo.name }} — kitgit{% endblock %} + +{% block content %} +<section class="kg-section"> + <h1 class="kg-title"> + <a href="/{{ owner.username }}/{{ repo.name }}">{{ owner.username }}/{{ repo.name }}</a> + · history + </h1> + {% let tab = "code" %} + {% let archive_ref = branch %} + {% include "partials/repo_tabs.html" %} + + <p class="kg-meta kg-breadcrumbs"> + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}">{{ branch }}</a> + {% for (name, full) in breadcrumbs %} + / + {% if loop.last %} + {{ name }} + {% else %} + <a href="/{{ owner.username }}/{{ repo.name }}/tree/{{ branch }}/{{ full }}">{{ name }}</a> + {% endif %} + {% endfor %} + </p> + + <div class="kg-blobbar"> + <span class="kg-meta">{{ path }}</span> + <div class="kg-blobbar__actions"> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/blob/{{ branch }}/{{ path }}">View file</a> + <a class="kg-btn kg-btn--ghost" href="/{{ owner.username }}/{{ repo.name }}/blame/{{ branch }}/{{ path }}">Blame</a> + </div> + </div> + + {% if commits.is_empty() %} + <p class="kg-muted">No commits touch this path.</p> + {% else %} + <ul class="kg-list"> + {% for c in commits %} + <li> + <span> + <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit"> + <span class="kg-icon" style="--icon:url('/static/icons/git-commit.svg')"></span> + <code>{{ c.short_id }}</code> + </a> + <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a> + {% if c.verified %} + <details class="kg-verify"> + <summary class="kg-badge kg-badge--verified">Verified</summary> + <div class="kg-verify__panel" role="dialog" aria-label="Commit signature"> + <p class="kg-verify__fp"> + <strong>{{ c.verify_fingerprint_label }}:</strong> + <code>{{ c.verify_fingerprint }}</code> + </p> + <p class="kg-meta">Verified on {{ c.verified_at }}</p> + </div> + </details> + {% endif %} + <span class="kg-muted"> — {{ c.author }}</span> + </span> + <span class="kg-meta">{{ c.time }}</span> + </li> + {% endfor %} + </ul> + {% endif %} +</section> +{% endblock %}