d83c62d6
Add file history view
a73x 2026-09-04 11:18
Commit message
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -35,6 +35,7 @@ pub fn router(state: AppState) -> Router { | |||
| 35 | .route("/{repo_name}/tree", axum::routing::get(repo::tree_root)) | 35 | .route("/{repo_name}/tree", axum::routing::get(repo::tree_root)) |
| 36 | .route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree)) | 36 | .route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree)) |
| 37 | .route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob)) | 37 | .route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob)) |
| 38 | .route("/{repo_name}/history/{*rest}", axum::routing::get(repo::history)) | ||
| 38 | .route("/{repo_name}/diff/{oid}", axum::routing::get(repo::diff)) | 39 | .route("/{repo_name}/diff/{oid}", axum::routing::get(repo::diff)) |
| 39 | .route("/{repo_name}/patches", axum::routing::get(repo::patches)) | 40 | .route("/{repo_name}/patches", axum::routing::get(repo::patches)) |
| 40 | .route( | 41 | .route( |
src/server/http/repo/history.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,163 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use axum::extract::{Path, State}; | ||
| 4 | use axum::response::{IntoResponse, Response}; | ||
| 5 | |||
| 6 | use super::{ | ||
| 7 | collab_counts, head_branch_name, list_branches, not_found, open_repo, AppState, OverviewCommit, | ||
| 8 | }; | ||
| 9 | |||
| 10 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 11 | #[template(path = "history.html")] | ||
| 12 | pub struct HistoryTemplate { | ||
| 13 | pub site_title: String, | ||
| 14 | pub repo_name: String, | ||
| 15 | pub active_section: String, | ||
| 16 | pub open_patches: usize, | ||
| 17 | pub open_issues: usize, | ||
| 18 | pub ref_name: String, | ||
| 19 | pub branches: Vec<String>, | ||
| 20 | pub file_path: String, | ||
| 21 | pub commits: Vec<OverviewCommit>, | ||
| 22 | } | ||
| 23 | |||
| 24 | /// The `(mode, blob oid)` a commit's tree holds at `path`, or `None` if the | ||
| 25 | /// path is absent. The mode is part of the comparison because a chmod | ||
| 26 | /// 100644→100755 keeps the same oid — `git log -- <path>` counts that as a | ||
| 27 | /// change, and so must we. | ||
| 28 | fn entry_at(commit: &git2::Commit, path: &str) -> Option<(i32, git2::Oid)> { | ||
| 29 | let tree = commit.tree().ok()?; | ||
| 30 | let entry = tree.get_path(std::path::Path::new(path)).ok()?; | ||
| 31 | // A directory at the path is not a file history entry; skip it. | ||
| 32 | if entry.kind() == Some(git2::ObjectType::Tree) { | ||
| 33 | return None; | ||
| 34 | } | ||
| 35 | Some((entry.filemode(), entry.id())) | ||
| 36 | } | ||
| 37 | |||
| 38 | /// Commits reachable from `head` in which the `(mode, oid)` at `path` differs | ||
| 39 | /// from the entry in *every* parent. This is git's default TREESAME | ||
| 40 | /// simplification: a merge whose entry matches any parent is suppressed (git | ||
| 41 | /// follows that parent instead), which keeps a clean non-fast-forward merge | ||
| 42 | /// from listing both itself and the source-branch commit. A root commit (no | ||
| 43 | /// parents) is included when it introduces the path. | ||
| 44 | /// | ||
| 45 | /// This is `git log -- <path>` without rename following: a move shows up as a | ||
| 46 | /// deletion and an addition, which is the honest answer a reader can | ||
| 47 | /// re-derive from the diffs it links to. | ||
| 48 | fn history_for_path( | ||
| 49 | repo: &git2::Repository, | ||
| 50 | head: &git2::Commit, | ||
| 51 | path: &str, | ||
| 52 | limit: usize, | ||
| 53 | ) -> Vec<OverviewCommit> { | ||
| 54 | let mut revwalk = match repo.revwalk() { | ||
| 55 | Ok(rw) => rw, | ||
| 56 | Err(_) => return Vec::new(), | ||
| 57 | }; | ||
| 58 | if revwalk.push(head.id()).is_err() { | ||
| 59 | return Vec::new(); | ||
| 60 | } | ||
| 61 | // Children-before-parents (newest-first), stable across commits that | ||
| 62 | // share a timestamp. Verified against a repo whose three file-touching | ||
| 63 | // commits all landed in the same second: `TIME` reordered them wrongly, | ||
| 64 | // while `TOPOLOGICAL` and the default (`NONE`) both held. `TOPOLOGICAL` is | ||
| 65 | // chosen over the default because it *guarantees* the order rather than | ||
| 66 | // happening to produce it. | ||
| 67 | let _ = revwalk.set_sorting(git2::Sort::TOPOLOGICAL); | ||
| 68 | |||
| 69 | revwalk | ||
| 70 | .filter_map(|oid| { | ||
| 71 | let oid = oid.ok()?; | ||
| 72 | let commit = repo.find_commit(oid).ok()?; | ||
| 73 | let current = entry_at(&commit, path); | ||
| 74 | |||
| 75 | // A commit is a history entry iff its entry at `path` differs from | ||
| 76 | // *every* parent — git's TREESAME simplification. A merge whose | ||
| 77 | // entry matches any parent is suppressed (comparing only parent 0 | ||
| 78 | // would list a clean merge alongside the source-branch commit that | ||
| 79 | // git log drops it for). A root commit (no parents) is included | ||
| 80 | // only when it introduces the path: "differs from all parents" is | ||
| 81 | // vacuously true for a parentless commit, so a root that lacks the | ||
| 82 | // file must be rejected explicitly. | ||
| 83 | let parents: Vec<git2::Commit> = (0..commit.parent_count()) | ||
| 84 | .filter_map(|i| commit.parent(i).ok()) | ||
| 85 | .collect(); | ||
| 86 | |||
| 87 | let is_entry = if parents.is_empty() { | ||
| 88 | current.is_some() | ||
| 89 | } else { | ||
| 90 | parents | ||
| 91 | .iter() | ||
| 92 | .all(|parent| entry_at(parent, path) != current) | ||
| 93 | }; | ||
| 94 | if !is_entry { | ||
| 95 | return None; | ||
| 96 | } | ||
| 97 | |||
| 98 | let id = oid.to_string(); | ||
| 99 | let short_id = id[..8.min(id.len())].to_string(); | ||
| 100 | let summary = commit.summary().unwrap_or("").to_string(); | ||
| 101 | let author = commit.author().name().unwrap_or("").to_string(); | ||
| 102 | let secs = commit.time().seconds(); | ||
| 103 | let date = chrono::TimeZone::timestamp_opt(&chrono::Utc, secs, 0) | ||
| 104 | .single() | ||
| 105 | .map(|dt| dt.format("%Y-%m-%d").to_string()) | ||
| 106 | .unwrap_or_default(); | ||
| 107 | Some(OverviewCommit { | ||
| 108 | id, | ||
| 109 | short_id, | ||
| 110 | summary, | ||
| 111 | author, | ||
| 112 | date, | ||
| 113 | }) | ||
| 114 | }) | ||
| 115 | .take(limit) | ||
| 116 | .collect() | ||
| 117 | } | ||
| 118 | |||
| 119 | pub async fn history( | ||
| 120 | Path((repo_name, rest)): Path<(String, String)>, | ||
| 121 | State(state): State<Arc<AppState>>, | ||
| 122 | ) -> Response { | ||
| 123 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 124 | Ok(r) => r, | ||
| 125 | Err(resp) => return resp, | ||
| 126 | }; | ||
| 127 | |||
| 128 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 129 | let branches = list_branches(&repo); | ||
| 130 | let (ref_name, file_path) = super::tree::split_ref_path(&rest); | ||
| 131 | let ref_name = if ref_name == "HEAD" { | ||
| 132 | head_branch_name(&repo) | ||
| 133 | } else { | ||
| 134 | ref_name | ||
| 135 | }; | ||
| 136 | |||
| 137 | // Resolve the ref so an unknown ref is a 404, but do NOT require the | ||
| 138 | // path to exist at HEAD: a deleted file's history is a legitimate query, | ||
| 139 | // and the walk will simply be empty for a path that was never tracked. | ||
| 140 | let obj = match repo.revparse_single(&ref_name) { | ||
| 141 | Ok(o) => o, | ||
| 142 | Err(_) => return not_found(&state, format!("Ref '{}' not found.", ref_name)), | ||
| 143 | }; | ||
| 144 | let commit = match obj.peel_to_commit() { | ||
| 145 | Ok(c) => c, | ||
| 146 | Err(_) => return not_found(&state, "Could not resolve ref to a commit."), | ||
| 147 | }; | ||
| 148 | |||
| 149 | let commits = history_for_path(&repo, &commit, &file_path, 200); | ||
| 150 | |||
| 151 | HistoryTemplate { | ||
| 152 | site_title: state.site_title.clone(), | ||
| 153 | repo_name, | ||
| 154 | active_section: "tree".to_string(), | ||
| 155 | open_patches, | ||
| 156 | open_issues, | ||
| 157 | ref_name, | ||
| 158 | branches, | ||
| 159 | file_path, | ||
| 160 | commits, | ||
| 161 | } | ||
| 162 | .into_response() | ||
| 163 | } | ||
src/server/http/repo/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | mod commits; | 1 | mod commits; |
| 2 | mod diff; | 2 | mod diff; |
| 3 | mod patch_diff; | 3 | mod patch_diff; |
| 4 | mod history; | ||
| 4 | mod issues; | 5 | mod issues; |
| 5 | mod overview; | 6 | mod overview; |
| 6 | mod patches; | 7 | mod patches; |
| @@ -11,6 +12,7 @@ mod tree; | |||
| 11 | pub use commits::{commits, commits_ref}; | 12 | pub use commits::{commits, commits_ref}; |
| 12 | pub use diff::diff; | 13 | pub use diff::diff; |
| 13 | pub use patch_diff::patch_diff; | 14 | pub use patch_diff::patch_diff; |
| 15 | pub use history::history; | ||
| 14 | pub use issues::{issue_detail, issues}; | 16 | pub use issues::{issue_detail, issues}; |
| 15 | pub use overview::overview; | 17 | pub use overview::overview; |
| 16 | pub use patches::{patch_detail, patches}; | 18 | pub use patches::{patch_detail, patches}; |
src/server/http/repo/tree.rs
| Old | New | ||
|---|---|---|---|
| @@ -48,7 +48,7 @@ pub struct BlobTemplate { | |||
| 48 | 48 | ||
| 49 | /// Split "ref/path/to/file" — first segment is ref, rest joined is path. | 49 | /// Split "ref/path/to/file" — first segment is ref, rest joined is path. |
| 50 | /// Returns ("HEAD", "") for empty input. | 50 | /// Returns ("HEAD", "") for empty input. |
| 51 | fn split_ref_path(input: &str) -> (String, String) { | 51 | pub fn split_ref_path(input: &str) -> (String, String) { |
| 52 | if input.is_empty() { | 52 | if input.is_empty() { |
| 53 | return ("HEAD".to_string(), String::new()); | 53 | return ("HEAD".to_string(), String::new()); |
| 54 | } | 54 | } |
src/server/http/templates/blob.html
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,9 @@ | |||
| 10 | <option value="/{{ repo_name }}/blob/{{ b }}/{{ file_path }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> | 10 | <option value="/{{ repo_name }}/blob/{{ b }}/{{ file_path }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> |
| 11 | {% endfor %} | 11 | {% endfor %} |
| 12 | </select> | 12 | </select> |
| 13 | Size: {{ size_display }}</p> | 13 | Size: {{ size_display }} |
| 14 | <a href="/{{ repo_name }}/history/{{ ref_name }}/{{ file_path }}">History</a> | ||
| 15 | </p> | ||
| 14 | {% if is_binary %} | 16 | {% if is_binary %} |
| 15 | <p style="color: #666; font-style: italic;">Binary file</p> | 17 | <p style="color: #666; font-style: italic;">Binary file</p> |
| 16 | {% else %} | 18 | {% else %} |
src/server/http/templates/history.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,42 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}History — {{ file_path }} — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>History: | ||
| 7 | <a href="/{{ repo_name }}/blob/{{ ref_name }}/{{ file_path }}">{{ file_path }}</a> | ||
| 8 | </h2> | ||
| 9 | <p style="color: #666;">Ref: | ||
| 10 | <select class="mono" onchange="location.href=this.value"> | ||
| 11 | {% for b in branches %} | ||
| 12 | <option value="/{{ repo_name }}/history/{{ b }}/{{ file_path }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> | ||
| 13 | {% endfor %} | ||
| 14 | </select> | ||
| 15 | </p> | ||
| 16 | {% if commits.is_empty() %} | ||
| 17 | <p style="color: #666;">No commits touched this file.</p> | ||
| 18 | {% else %} | ||
| 19 | <div class="table-scroll"> | ||
| 20 | <table> | ||
| 21 | <thead> | ||
| 22 | <tr> | ||
| 23 | <th>Hash</th> | ||
| 24 | <th>Message</th> | ||
| 25 | <th>Author</th> | ||
| 26 | <th>Date</th> | ||
| 27 | </tr> | ||
| 28 | </thead> | ||
| 29 | <tbody> | ||
| 30 | {% for c in commits %} | ||
| 31 | <tr> | ||
| 32 | <td class="mono"><a href="/{{ repo_name }}/diff/{{ c.id }}">{{ c.short_id }}</a></td> | ||
| 33 | <td>{{ c.summary }}</td> | ||
| 34 | <td style="color: #666;">{{ c.author }}</td> | ||
| 35 | <td class="mono" style="color: #666;">{{ c.date }}</td> | ||
| 36 | </tr> | ||
| 37 | {% endfor %} | ||
| 38 | </tbody> | ||
| 39 | </table> | ||
| 40 | </div> | ||
| 41 | {% endif %} | ||
| 42 | {% endblock %} | ||
tests/file_history_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,162 @@ | |||
| 1 | //! The file-history view lists exactly the commits that touched a path. | ||
| 2 | //! | ||
| 3 | //! Like GitHub's "History" button on a blob: open a file, click History, see | ||
| 4 | //! the line of commits that changed it. A commit that touched a *different* | ||
| 5 | //! file must not appear, and a commit that *deleted* the file must. | ||
| 6 | |||
| 7 | mod common; | ||
| 8 | |||
| 9 | use common::ServerHarness; | ||
| 10 | |||
| 11 | #[test] | ||
| 12 | fn history_lists_only_commits_that_touched_the_file() { | ||
| 13 | let harness = ServerHarness::new("file-history"); | ||
| 14 | let repo = harness.work_repo(); | ||
| 15 | |||
| 16 | let first = repo.commit_file("tracked.rs", "v1\n", "add tracked.rs"); | ||
| 17 | let _other = repo.commit_file("other.rs", "x\n", "touch a different file"); | ||
| 18 | let second = repo.commit_file("tracked.rs", "v2\n", "change tracked.rs"); | ||
| 19 | harness.push_head(); | ||
| 20 | |||
| 21 | let name = harness.repo_name(); | ||
| 22 | let body = harness | ||
| 23 | .get_ok(&format!("/{name}/history/main/tracked.rs")) | ||
| 24 | .body; | ||
| 25 | |||
| 26 | // Both commits that touched tracked.rs appear, newest first. | ||
| 27 | let first_pos = body.find(&first[..8]).expect("first commit short id shown"); | ||
| 28 | let second_pos = body | ||
| 29 | .find(&second[..8]) | ||
| 30 | .expect("second commit short id shown"); | ||
| 31 | assert!( | ||
| 32 | second_pos < first_pos, | ||
| 33 | "history is not newest-first:\n{body}" | ||
| 34 | ); | ||
| 35 | |||
| 36 | // The commit that only touched other.rs is absent. | ||
| 37 | assert!( | ||
| 38 | !body.contains(&_other[..8]), | ||
| 39 | "history lists a commit that did not touch the file:\n{body}" | ||
| 40 | ); | ||
| 41 | |||
| 42 | assert!( | ||
| 43 | body.contains("change tracked.rs") && body.contains("add tracked.rs"), | ||
| 44 | "history is missing commit summaries:\n{body}" | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | |||
| 48 | #[test] | ||
| 49 | fn history_view_is_linked_from_the_blob_page() { | ||
| 50 | let harness = ServerHarness::new("file-history-link"); | ||
| 51 | let repo = harness.work_repo(); | ||
| 52 | repo.commit_file("tracked.rs", "v1\n", "add tracked.rs"); | ||
| 53 | harness.push_head(); | ||
| 54 | |||
| 55 | let name = harness.repo_name(); | ||
| 56 | let blob = harness | ||
| 57 | .get_ok(&format!("/{name}/blob/main/tracked.rs")) | ||
| 58 | .body; | ||
| 59 | assert!( | ||
| 60 | blob.contains(&format!("/{name}/history/main/tracked.rs")), | ||
| 61 | "the blob page has no History link:\n{blob}" | ||
| 62 | ); | ||
| 63 | } | ||
| 64 | |||
| 65 | #[test] | ||
| 66 | fn history_for_a_path_that_was_never_tracked_is_empty() { | ||
| 67 | let harness = ServerHarness::new("file-history-missing"); | ||
| 68 | harness.work_repo().commit_file("tracked.rs", "v1\n", "add"); | ||
| 69 | harness.push_head(); | ||
| 70 | |||
| 71 | let name = harness.repo_name(); | ||
| 72 | let body = harness | ||
| 73 | .get_ok(&format!("/{name}/history/main/nope.rs")) | ||
| 74 | .body; | ||
| 75 | assert!( | ||
| 76 | body.contains("No commits touched this file."), | ||
| 77 | "a never-tracked path should say so, not 404 or list commits:\n{body}" | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | |||
| 81 | #[test] | ||
| 82 | fn a_commit_that_deletes_the_file_appears_in_its_history() { | ||
| 83 | let harness = ServerHarness::new("file-history-deletion"); | ||
| 84 | let repo = harness.work_repo(); | ||
| 85 | |||
| 86 | let added = repo.commit_file("doomed.rs", "v1\n", "add doomed.rs"); | ||
| 87 | // Delete the file in a second commit. | ||
| 88 | repo.git(&["rm", "doomed.rs"]); | ||
| 89 | repo.git(&["commit", "-m", "delete doomed.rs"]); | ||
| 90 | harness.push_head(); | ||
| 91 | |||
| 92 | let name = harness.repo_name(); | ||
| 93 | let body = harness | ||
| 94 | .get_ok(&format!("/{name}/history/main/doomed.rs")) | ||
| 95 | .body; | ||
| 96 | |||
| 97 | // Both the adding commit and the deleting commit appear. | ||
| 98 | assert!( | ||
| 99 | body.contains(&added[..8]), | ||
| 100 | "the commit that added the file is missing from its history:\n{body}" | ||
| 101 | ); | ||
| 102 | assert!( | ||
| 103 | body.contains("delete doomed.rs"), | ||
| 104 | "the commit that deleted the file is missing from its history:\n{body}" | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | /// A clean non-fast-forward merge whose file entry matches a non-first parent | ||
| 109 | /// must be suppressed, the way `git log -- <path>` suppresses it. The old code | ||
| 110 | /// compared only parent 0 and listed the merge alongside the source-branch | ||
| 111 | /// commit. | ||
| 112 | #[test] | ||
| 113 | fn a_clean_merge_matching_any_parent_is_suppressed() { | ||
| 114 | let harness = ServerHarness::new("file-history-merge"); | ||
| 115 | let repo = harness.work_repo(); | ||
| 116 | |||
| 117 | // A: add f.rs = v1 on main. | ||
| 118 | repo.commit_file("f.rs", "v1\n", "add f"); | ||
| 119 | // Branch from main, change f.rs = v2 (the source-branch commit). | ||
| 120 | repo.git(&["checkout", "-b", "topic"]); | ||
| 121 | let topic_commit = repo.commit_file("f.rs", "v2\n", "change f on topic"); | ||
| 122 | // Back on main: touch a *different* file so f.rs stays v1, then merge. | ||
| 123 | repo.git(&["checkout", "main"]); | ||
| 124 | repo.commit_file("other.txt", "x\n", "touch other on main"); | ||
| 125 | repo.git(&["merge", "--no-ff", "topic", "-m", "merge topic"]); | ||
| 126 | harness.push_head(); | ||
| 127 | |||
| 128 | let name = harness.repo_name(); | ||
| 129 | let body = harness.get_ok(&format!("/{name}/history/main/f.rs")).body; | ||
| 130 | |||
| 131 | let merge_oid = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 132 | assert!( | ||
| 133 | !body.contains(&merge_oid[..8]), | ||
| 134 | "the merge commit (TREESAME to a parent) should be suppressed:\n{body}" | ||
| 135 | ); | ||
| 136 | assert!( | ||
| 137 | body.contains(&topic_commit[..8]), | ||
| 138 | "the source-branch commit that changed the file should appear:\n{body}" | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | |||
| 142 | /// A mode-only change (chmod +x) keeps the same blob oid, so an oid-only | ||
| 143 | /// comparison omits it. `git log -- <path>` includes it, and so must we. | ||
| 144 | #[test] | ||
| 145 | fn a_mode_only_change_appears_in_the_history() { | ||
| 146 | let harness = ServerHarness::new("file-history-mode"); | ||
| 147 | let repo = harness.work_repo(); | ||
| 148 | |||
| 149 | repo.commit_file("s.sh", "echo hi\n", "add script"); | ||
| 150 | repo.git(&["update-index", "--chmod=+x", "s.sh"]); | ||
| 151 | repo.git(&["commit", "-m", "chmod +x"]); | ||
| 152 | harness.push_head(); | ||
| 153 | |||
| 154 | let name = harness.repo_name(); | ||
| 155 | let body = harness.get_ok(&format!("/{name}/history/main/s.sh")).body; | ||
| 156 | |||
| 157 | let chmod_oid = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); | ||
| 158 | assert!( | ||
| 159 | body.contains(&chmod_oid[..8]), | ||
| 160 | "a mode-only change (same blob oid) must appear in file history:\n{body}" | ||
| 161 | ); | ||
| 162 | } | ||