a73x

d83c62d6

Add file history view

a73x   2026-09-04 11:18

Commit message
Add file history view

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 &nbsp; Size: {{ size_display }}</p> 13 &nbsp; Size: {{ size_display }}
14 &nbsp; <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 }