kitgit

tirbofish/kitgit · commit

91ca4e1b8da75e5070d27dcae2807dc784bcb423

blame history

Verified

Thribhu K <4tkbytes@pm.me> · 2026-07-28 15:06

view full diff

diff --git a/src/git/blame.rs b/src/git/blame.rs
new file mode 100644
index 0000000..1b55286
--- /dev/null
+++ b/src/git/blame.rs
@@ -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)
+}
diff --git a/src/git/mod.rs b/src/git/mod.rs
index b402e6d..9dc4ce8 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -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};
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c4b4cbb..2a80567 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -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))
diff --git a/src/web/routes.rs b/src/web/routes.rs
index c080f6f..4d36243 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -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>,
diff --git a/src/web/templates.rs b/src/web/templates.rs
index a2932d4..093a54f 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -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 {
diff --git a/static/brand.css b/static/brand.css
index d50db11..e69b916 100644
--- a/static/brand.css
+++ b/static/brand.css
@@ -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);
diff --git a/templates/repo_blame.html b/templates/repo_blame.html
new file mode 100644
index 0000000..e3f291d
--- /dev/null
+++ b/templates/repo_blame.html
@@ -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 %}
diff --git a/templates/repo_blob.html b/templates/repo_blob.html
index 5b0ff48..f00dbf7 100644
--- a/templates/repo_blob.html
+++ b/templates/repo_blob.html
@@ -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
diff --git a/templates/repo_history.html b/templates/repo_history.html
new file mode 100644
index 0000000..92e644f
--- /dev/null
+++ b/templates/repo_history.html
@@ -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 %}