Skip to main content

max / makenotwork

Port commit history, diff and blame from libgit2 to gitoxide history.rs now reads through gix: log and file log walk with gix's revision traversal, commit detail decodes the commit object directly, blame uses gix-blame with the suspect commit standing in for BlameOptions::newest_commit, and the diff is built from gix's tree-with-rewrites changes rendered through imara's unified-diff sink. With that the last read path is off libgit2, so with_repo hands its closure a single gix handle and the git2-to-gix id bridge is gone. repo_info moved over with it, since it was the only remaining caller holding a git2 handle. Two behavior changes worth naming. Renames now collapse into one entry with the previous path, where libgit2 was called without find_similar and reported an addition plus a deletion, so the Renamed status the templates already carry was unreachable. Blame no longer reports boundary hunks: gitoxide has no equivalent flag and nothing in the view rendered it differently. Diffs had no test coverage before. Three tests cover hunk line numbering, HTML escaping of diff content, and rename detection.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 21:14 UTC
Signed with PGP, not checked
Commit: 303d73e967a1b3e5e64930bb047b29321d6ddc2e
Parent: 54c8f28
6 files changed, +427 insertions, -222 deletions
@@ -1,52 +1,86 @@
1 1 //! Commit history, log, detail, diff, and blame operations.
2 2
3 - use std::path::Path;
4 -
5 - use git2::{Diff, DiffOptions, Oid, Repository, Sort};
3 + use gix::{
4 + ObjectId, Repository,
5 + bstr::ByteSlice,
6 + diff::blob::{
7 + Algorithm, InternedInput, UnifiedDiff,
8 + unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader},
9 + },
10 + revision::walk::Sorting,
11 + traverse::commit::simple::CommitTimeOrder,
12 + };
6 13
7 14 use super::{
8 15 BlameLine, CommitDetail, CommitInfo, DiffFile, DiffHunk, DiffLine, DiffStatus, GitError,
9 16 ParentRef,
10 17 };
11 18
19 + /// Newest-commit-first history order, matching what the log pages have always
20 + /// shown (libgit2's `Sort::TIME`).
21 + const NEWEST_FIRST: Sorting = Sorting::ByCommitTime(CommitTimeOrder::NewestFirst);
22 +
23 + /// Shorten an object id for display, as the log and commit pages render it.
24 + fn short(oid: &str) -> String {
25 + oid[..7.min(oid.len())].to_string()
26 + }
27 +
28 + /// Build the log entry for a commit. Author identity comes from the author
29 + /// signature, the displayed timestamp from the commit (committer) time, which
30 + /// is what the log has always shown.
31 + fn commit_info(commit: &gix::Commit<'_>) -> CommitInfo {
32 + let oid = commit.id().to_string();
33 + let short_oid = short(&oid);
34 +
35 + let summary = commit
36 + .message_raw()
37 + .ok()
38 + .and_then(|m| m.lines().next().map(|l| l.to_str_lossy().into_owned()))
39 + .unwrap_or_default();
40 +
41 + let author = commit.author().ok();
42 + let author_name = author.map_or_else(
43 + || "Unknown".to_string(),
44 + |a| a.name.to_str_lossy().into_owned(),
45 + );
46 + let author_email = author
47 + .map(|a| a.email.to_str_lossy().into_owned())
48 + .unwrap_or_default();
49 +
50 + let seconds = commit.time().map(|t| t.seconds).unwrap_or_default();
51 + let time = chrono::DateTime::from_timestamp(seconds, 0).unwrap_or_default();
52 + let time_formatted = time.format("%Y-%m-%d %H:%M").to_string();
53 +
54 + CommitInfo {
55 + oid,
56 + short_oid,
57 + summary,
58 + author_name,
59 + author_email,
60 + time,
61 + time_formatted,
62 + }
63 + }
64 +
12 65 /// Walk commit history with pagination.
13 66 pub fn commit_log(
14 67 repo: &Repository,
15 - commit_oid: Oid,
68 + commit_oid: ObjectId,
16 69 limit: usize,
17 70 offset: usize,
18 71 ) -> Result<Vec<CommitInfo>, GitError> {
19 - let mut revwalk = repo.revwalk()?;
20 - revwalk.push(commit_oid)?;
21 - revwalk.set_sorting(Sort::TIME)?;
72 + let walk = repo
73 + .rev_walk([commit_oid])
74 + .sorting(NEWEST_FIRST)
75 + .all()
76 + .map_err(|_| GitError::RefNotFound)?;
22 77
23 - let commits: Vec<CommitInfo> = revwalk
78 + let commits: Vec<CommitInfo> = walk
24 79 .filter_map(std::result::Result::ok)
25 - .filter_map(|oid| repo.find_commit(oid).ok())
80 + .filter_map(|info| repo.find_commit(info.id).ok())
26 81 .skip(offset)
27 82 .take(limit)
28 - .map(|commit| {
29 - let oid = commit.id().to_string();
30 - let short_oid = oid[..7.min(oid.len())].to_string();
31 - let message = commit.message().unwrap_or("");
32 - let summary = message.lines().next().unwrap_or("").to_string();
33 - let author = commit.author();
34 - let author_name = author.name().unwrap_or("Unknown").to_string();
35 - let author_email = author.email().unwrap_or("").to_string();
36 - let time =
37 - chrono::DateTime::from_timestamp(commit.time().seconds(), 0).unwrap_or_default();
38 - let time_formatted = time.format("%Y-%m-%d %H:%M").to_string();
39 -
40 - CommitInfo {
41 - oid,
42 - short_oid,
43 - summary,
44 - author_name,
45 - author_email,
46 - time,
47 - time_formatted,
48 - }
49 - })
83 + .map(|commit| commit_info(&commit))
50 84 .collect();
51 85
52 86 Ok(commits)
@@ -58,56 +92,44 @@
58 92 /// OID at `path` differs from the parent's blob OID (or the file was added/removed).
59 93 pub fn file_commit_log(
60 94 repo: &Repository,
61 - commit_oid: Oid,
95 + commit_oid: ObjectId,
62 96 path: &str,
63 97 limit: usize,
64 98 offset: usize,
65 99 max_walk: usize,
66 100 ) -> Result<Vec<CommitInfo>, GitError> {
67 - let mut revwalk = repo.revwalk()?;
68 - revwalk.push(commit_oid)?;
69 - revwalk.set_sorting(Sort::TIME)?;
101 + let walk = repo
102 + .rev_walk([commit_oid])
103 + .sorting(NEWEST_FIRST)
104 + .all()
105 + .map_err(|_| GitError::RefNotFound)?;
70 106
71 - let file_path = Path::new(path);
72 107 let mut result = Vec::new();
73 108 let mut skipped = 0;
74 109
75 - for (walked, oid_result) in revwalk.enumerate() {
110 + for (walked, info) in walk.enumerate() {
76 111 if walked >= max_walk || result.len() >= limit {
77 112 break;
78 113 }
79 114
80 - let Ok(oid) = oid_result else {
115 + let Ok(info) = info else {
81 116 continue;
82 117 };
83 - let Ok(commit) = repo.find_commit(oid) else {
84 - continue;
85 - };
86 -
87 - let Ok(tree) = commit.tree() else {
118 + let Ok(commit) = repo.find_commit(info.id) else {
88 119 continue;
89 120 };
90 121
91 - // Get the blob OID at `path` in this commit (None if file doesn't exist)
92 - let current_blob = tree.get_path(file_path).ok().map(|entry| entry.id());
122 + // The blob OID at `path` in this commit (None if the file is absent)
123 + let current_blob = blob_id_at(&commit, path);
93 124
94 - // Compare against parent(s)
95 - let changed = if commit.parent_count() == 0 {
96 - // Root commit: file is "changed" if it exists
97 - current_blob.is_some()
98 - } else {
99 - // Check first parent only (standard git log behavior)
100 - match commit.parent(0) {
101 - Ok(parent) => {
102 - let parent_blob = parent
103 - .tree()
104 - .ok()
105 - .and_then(|t| t.get_path(file_path).ok())
106 - .map(|entry| entry.id());
107 - current_blob != parent_blob
108 - }
125 + // Compare against the first parent only (standard git log behavior).
126 + // A root commit counts as a change if the file exists in it.
127 + let changed = match commit.parent_ids().next() {
128 + None => current_blob.is_some(),
129 + Some(parent_id) => match repo.find_commit(parent_id) {
130 + Ok(parent) => current_blob != blob_id_at(&parent, path),
109 131 Err(_) => current_blob.is_some(),
110 - }
132 + },
111 133 };
112 134
113 135 if !changed {
@@ -119,60 +141,52 @@
119 141 continue;
120 142 }
121 143
122 - let oid_str = commit.id().to_string();
123 - let short_oid = oid_str[..7.min(oid_str.len())].to_string();
124 - let message = commit.message().unwrap_or("");
125 - let summary = message.lines().next().unwrap_or("").to_string();
126 - let author = commit.author();
127 - let author_name = author.name().unwrap_or("Unknown").to_string();
128 - let author_email = author.email().unwrap_or("").to_string();
129 - let time = chrono::DateTime::from_timestamp(commit.time().seconds(), 0).unwrap_or_default();
130 - let time_formatted = time.format("%Y-%m-%d %H:%M").to_string();
131 -
132 - result.push(CommitInfo {
133 - oid: oid_str,
134 - short_oid,
135 - summary,
136 - author_name,
137 - author_email,
138 - time,
139 - time_formatted,
140 - });
144 + result.push(commit_info(&commit));
141 145 }
142 146
143 147 Ok(result)
144 148 }
145 149
150 + /// The object id of the entry at `path` in a commit's tree, if it exists.
151 + fn blob_id_at(commit: &gix::Commit<'_>, path: &str) -> Option<ObjectId> {
152 + let mut tree = commit.tree().ok()?;
153 + Some(tree.peel_to_entry_by_path(path).ok()??.oid().to_owned())
154 + }
155 +
146 156 /// Get full commit detail for the commit page.
147 - pub fn commit_detail(repo: &Repository, oid: Oid) -> Result<CommitDetail, GitError> {
157 + pub fn commit_detail(repo: &Repository, oid: ObjectId) -> Result<CommitDetail, GitError> {
148 158 let commit = repo.find_commit(oid).map_err(|_| GitError::RefNotFound)?;
149 159
150 160 let oid_str = oid.to_string();
151 - let short_oid = oid_str[..7.min(oid_str.len())].to_string();
161 + let short_oid = short(&oid_str);
152 162
153 - let message = commit.message().unwrap_or("").to_string();
163 + let message = commit
164 + .message_raw()
165 + .map(|m| m.to_str_lossy().into_owned())
166 + .unwrap_or_default();
154 167 let summary = message.lines().next().unwrap_or("").to_string();
155 168
156 - let author = commit.author();
157 - let committer = commit.committer();
169 + let author = commit.author().ok();
170 + let committer = commit.committer().ok();
158 171
159 - let author_time = chrono::DateTime::from_timestamp(author.when().seconds(), 0)
160 - .unwrap_or_default()
161 - .format("%Y-%m-%d %H:%M UTC")
162 - .to_string();
163 - let committer_time = chrono::DateTime::from_timestamp(committer.when().seconds(), 0)
164 - .unwrap_or_default()
165 - .format("%Y-%m-%d %H:%M UTC")
166 - .to_string();
172 + let format_time = |sig: Option<gix::actor::SignatureRef<'_>>| {
173 + let seconds = sig
174 + .and_then(|s| s.time().ok())
175 + .map(|t| t.seconds)
176 + .unwrap_or_default();
177 + chrono::DateTime::from_timestamp(seconds, 0)
178 + .unwrap_or_default()
179 + .format("%Y-%m-%d %H:%M UTC")
180 + .to_string()
181 + };
167 182
168 183 let parents: Vec<ParentRef> = commit
169 184 .parent_ids()
170 185 .map(|pid| {
171 186 let s = pid.to_string();
172 - let short = s[..7.min(s.len())].to_string();
173 187 ParentRef {
188 + short_oid: short(&s),
174 189 oid: s,
175 - short_oid: short,
176 190 }
177 191 })
178 192 .collect();
@@ -182,126 +196,209 @@
182 196 short_oid,
183 197 summary,
184 198 full_message: message,
185 - author_name: author.name().unwrap_or("Unknown").to_string(),
186 - author_email: author.email().unwrap_or("").to_string(),
187 - author_time,
188 - committer_name: committer.name().unwrap_or("Unknown").to_string(),
189 - committer_email: committer.email().unwrap_or("").to_string(),
190 - committer_time,
199 + author_name: author.map_or_else(
200 + || "Unknown".to_string(),
201 + |a| a.name.to_str_lossy().into_owned(),
202 + ),
203 + author_email: author
204 + .map(|a| a.email.to_str_lossy().into_owned())
205 + .unwrap_or_default(),
206 + author_time: format_time(author),
207 + committer_name: committer.map_or_else(
208 + || "Unknown".to_string(),
209 + |c| c.name.to_str_lossy().into_owned(),
210 + ),
211 + committer_email: committer
212 + .map(|c| c.email.to_str_lossy().into_owned())
213 + .unwrap_or_default(),
214 + committer_time: format_time(committer),
191 215 parents,
192 216 })
193 217 }
194 218
219 + /// Escape the HTML-significant characters in a line of file content.
220 + fn escape(content: &[u8]) -> String {
221 + content
222 + .to_str_lossy()
223 + .replace('&', "&amp;")
224 + .replace('<', "&lt;")
225 + .replace('>', "&gt;")
226 + }
227 +
228 + /// Binary detection, matching the blob reader: a null byte in the first 8KB.
229 + fn looks_binary(data: &[u8]) -> bool {
230 + data[..data.len().min(8192)].contains(&0)
231 + }
232 +
233 + /// Collects unified-diff hunks into the domain types, stopping once
234 + /// `max_lines` lines have been taken.
235 + struct HunkCollector {
236 + hunks: Vec<DiffHunk>,
237 + additions: usize,
238 + deletions: usize,
239 + total_lines: usize,
240 + max_lines: usize,
241 + truncated: bool,
242 + }
243 +
244 + impl ConsumeHunk for HunkCollector {
245 + type Out = Self;
246 +
247 + fn consume_hunk(
248 + &mut self,
249 + header: HunkHeader,
250 + lines: &[(DiffLineKind, &[u8])],
251 + ) -> std::io::Result<()> {
252 + if self.truncated {
253 + return Ok(());
254 + }
255 +
256 + let text = format!(
257 + "@@ -{},{} +{},{} @@",
258 + header.before_hunk_start,
259 + header.before_hunk_len,
260 + header.after_hunk_start,
261 + header.after_hunk_len
262 + );
263 +
264 + let mut old_lineno = header.before_hunk_start;
265 + let mut new_lineno = header.after_hunk_start;
266 + let mut out = Vec::with_capacity(lines.len());
267 +
268 + for (kind, content) in lines {
269 + if self.total_lines >= self.max_lines {
270 + self.truncated = true;
271 + break;
272 + }
273 +
274 + let (origin, old, new) = match kind {
275 + DiffLineKind::Context => {
276 + let pair = (Some(old_lineno), Some(new_lineno));
277 + old_lineno += 1;
278 + new_lineno += 1;
279 + (' ', pair.0, pair.1)
280 + }
281 + DiffLineKind::Add => {
282 + let n = new_lineno;
283 + new_lineno += 1;
284 + self.additions += 1;
285 + ('+', None, Some(n))
286 + }
287 + DiffLineKind::Remove => {
288 + let n = old_lineno;
289 + old_lineno += 1;
290 + self.deletions += 1;
291 + ('-', Some(n), None)
292 + }
293 + };
294 +
295 + out.push(DiffLine {
296 + origin,
297 + content: escape(content),
298 + old_lineno: old,
299 + new_lineno: new,
300 + });
301 + self.total_lines += 1;
302 + }
303 +
304 + self.hunks.push(DiffHunk {
305 + header: text,
306 + lines: out,
307 + });
308 + Ok(())
309 + }
310 +
311 + fn finish(self) -> Self::Out {
312 + self
313 + }
314 + }
315 +
195 316 /// Generate a diff for a commit against its first parent (or empty tree for root commits).
196 317 pub fn commit_diff(
197 318 repo: &Repository,
198 - oid: Oid,
319 + oid: ObjectId,
199 320 max_files: usize,
200 321 max_lines_per_file: usize,
201 322 ) -> Result<Vec<DiffFile>, GitError> {
202 323 let commit = repo.find_commit(oid).map_err(|_| GitError::RefNotFound)?;
203 324 let commit_tree = commit.tree().map_err(|_| GitError::TreeNotFound)?;
204 325
205 - let parent_tree = if commit.parent_count() > 0 {
206 - let parent = commit.parent(0)?;
207 - Some(parent.tree().map_err(|_| GitError::TreeNotFound)?)
208 - } else {
209 - None
326 + let parent_tree = match commit.parent_ids().next() {
327 + Some(parent_id) => Some(
328 + repo.find_commit(parent_id)
329 + .map_err(|_| GitError::RefNotFound)?
330 + .tree()
331 + .map_err(|_| GitError::TreeNotFound)?,
332 + ),
333 + None => None,
210 334 };
211 335
212 - let mut opts = DiffOptions::new();
213 - opts.context_lines(3);
214 - let diff: Diff =
215 - repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut opts))?;
336 + let changes = repo
337 + .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
338 + .map_err(|_| GitError::TreeNotFound)?;
216 339
217 - let num_deltas = diff.deltas().len();
218 - let mut files = Vec::with_capacity(num_deltas);
340 + let mut files = Vec::with_capacity(changes.len());
219 341
220 - for (file_idx, delta) in diff.deltas().enumerate() {
221 - let new_file = delta.new_file();
222 - let old_file = delta.old_file();
342 + for (file_idx, change) in changes.iter().enumerate() {
343 + // Directories are not rendered as diff entries; only their contents are.
344 + if change.entry_mode().is_tree() {
345 + continue;
346 + }
223 347
224 - let path = new_file
225 - .path()
226 - .and_then(|p| p.to_str())
227 - .unwrap_or("")
228 - .to_string();
348 + let (path, old_path, status, old_id, new_id) = describe(change);
229 349
230 - let status = match delta.status() {
231 - git2::Delta::Added => DiffStatus::Added,
232 - git2::Delta::Deleted => DiffStatus::Deleted,
233 - git2::Delta::Renamed => DiffStatus::Renamed,
234 - _ => DiffStatus::Modified,
235 - };
350 + let old_data = old_id
351 + .map(|id| read_blob(repo, id))
352 + .transpose()?
353 + .unwrap_or_default();
354 + let new_data = new_id
355 + .map(|id| read_blob(repo, id))
356 + .transpose()?
357 + .unwrap_or_default();
236 358
237 - let old_path = if matches!(status, DiffStatus::Renamed) {
238 - old_file.path().and_then(|p| p.to_str()).map(String::from)
239 - } else {
240 - None
241 - };
359 + let is_binary = looks_binary(&old_data) || looks_binary(&new_data);
242 360
243 - let is_binary = new_file.is_binary() || old_file.is_binary();
244 -
245 - // Only collect hunks for first max_files files (and non-binary)
361 + // Only collect hunks for the first max_files files (and non-binary)
246 362 let collect_hunks = file_idx < max_files && !is_binary;
247 363
248 364 let mut hunks = Vec::new();
249 - let mut additions: usize = 0;
250 - let mut deletions: usize = 0;
251 - let mut total_lines: usize = 0;
365 + let mut additions = 0;
366 + let mut deletions = 0;
252 367 let mut truncated = false;
253 368
254 - if collect_hunks {
255 - // Use patch to get per-file hunks
256 - if let Ok(Some(ref patch)) = git2::Patch::from_diff(&diff, file_idx) {
257 - for hunk_idx in 0..patch.num_hunks() {
258 - if let Ok((hunk_header, _)) = patch.hunk(hunk_idx) {
259 - let header = String::from_utf8_lossy(hunk_header.header()).to_string();
260 - let mut lines = Vec::new();
Lines truncated
@@ -1,4 +1,5 @@
1 - //! Git abstraction layer, pure git2 wrapper with no HTTP/Axum concerns.
1 + //! Git abstraction layer, a thin wrapper with no HTTP/Axum concerns. Reads go
2 + //! through gitoxide; repository creation is still libgit2.
2 3 //!
3 4 //! All functions take a filesystem path and return `Result<T>`.
4 5 //! Repository is opened per-request (cheap, just file descriptors).
@@ -264,20 +265,16 @@
264 265 Repository::open_bare(&canonical_repo).map_err(|_| GitError::RepoNotFound)
265 266 }
266 267
267 - /// Open a bare repository at an already-resolved, validated path (e.g. one
268 - /// returned by [`repo_disk_path`]). Used to (re)open the repo inside a
269 - /// `spawn_blocking` closure, since `git2::Repository` is `!Send` and cannot
270 - /// cross the await boundary.
271 - pub(crate) fn open_repo_at(repo_path: &Path) -> Result<Repository, GitError> {
272 - Repository::open_bare(repo_path).map_err(|_| GitError::RepoNotFound)
268 + /// Open a bare repository with gitoxide, with the same path traversal
269 + /// validation [`open_repo`] applies.
270 + pub(crate) fn open_gix_repo(
271 + repos_root: &Path,
272 + owner: &str,
273 + repo: &str,
274 + ) -> Result<gix::Repository, GitError> {
275 + open_gix_repo_at(&repo_disk_path(repos_root, owner, repo)?)
273 276 }
274 277
275 - /// Open a bare repository with gitoxide at an already-resolved, validated path.
276 - ///
277 - /// Sibling of [`open_repo_at`] while the libgit2 port is in flight. Unlike
278 - /// `git2::Repository`, `gix::Repository` is `Send`, so once every read path has
279 - /// moved over, the reopen-per-call dance in `routes::git::ResolvedRepo` can go
280 - /// away with it.
281 278 /// Opened with `isolated()` options, so no ambient git configuration (the
282 279 /// service account's `~/.gitconfig`, `/etc/gitconfig`, `GIT_*` environment
283 280 /// variables) can change how a visitor's repository renders.
@@ -285,26 +282,19 @@
285 282 gix::open_opts(repo_path, gix::open::Options::isolated()).map_err(|_| GitError::RepoNotFound)
286 283 }
287 284
288 - /// Convert a gitoxide object id to a libgit2 one.
289 - ///
290 - /// Transitional: the ported modules hand back `gix::ObjectId` while
291 - /// `git/history.rs` still takes `git2::Oid`. Delete this along with the last
292 - /// git2 read path.
293 - pub(crate) fn to_git2_oid(id: gix::ObjectId) -> git2::Oid {
294 - git2::Oid::from_bytes(id.as_bytes()).expect("gix and libgit2 agree on hash length")
295 - }
296 -
297 285 /// Get basic repository info.
298 - pub fn repo_info(repo: &Repository, name: &str) -> RepoInfo {
286 + pub fn repo_info(repo: &gix::Repository, name: &str) -> RepoInfo {
299 287 let description = std::fs::read_to_string(repo.path().join("description"))
300 288 .ok()
301 289 .filter(|d| !d.starts_with("Unnamed repository"));
302 290
291 + // An unborn HEAD still names the branch it will point at, which is what the
292 + // browse pages want; an unreadable one falls back to the conventional name.
303 293 let default_branch = repo
304 - .head()
294 + .head_name()
305 295 .ok()
306 - .and_then(|r| r.shorthand().ok().map(String::from))
307 - .unwrap_or_else(|| "main".to_string());
296 + .flatten()
297 + .map_or_else(|| "main".to_string(), |name| name.shorten().to_string());
308 298
309 299 RepoInfo {
310 300 name: name.to_string(),
@@ -573,6 +563,9 @@
573 563 .commit(Some("refs/heads/main"), &sig, &sig, "c", &tree, &[])
574 564 .unwrap();
575 565
566 + let repo = open_gix(&bare_path);
567 + let commit_oid = gix::ObjectId::from_bytes_or_panic(commit_oid.as_bytes());
568 +
576 569 assert!(matches!(
577 570 blame_file(&repo, commit_oid, "big.txt"),
578 571 Err(GitError::PathNotFound)
@@ -628,11 +621,11 @@
628 621
629 622 #[test]
630 623 fn commit_log_returns_commits() {
631 - let (tmp, bare_path) = make_test_repo();
632 - let repo = open_repo(tmp.path(), "owner", "testrepo").unwrap();
633 - let oid = resolve_ref(&open_gix(&bare_path), "main").unwrap();
624 + let (_tmp, bare_path) = make_test_repo();
625 + let repo = open_gix(&bare_path);
626 + let oid = resolve_ref(&repo, "main").unwrap();
634 627
635 - let commits = commit_log(&repo, to_git2_oid(oid), 10, 0).unwrap();
628 + let commits = commit_log(&repo, oid, 10, 0).unwrap();
636 629 assert_eq!(commits.len(), 1);
637 630 assert_eq!(commits[0].summary, "Initial commit");
638 631 assert_eq!(commits[0].author_name, "Test");
@@ -662,11 +655,11 @@
662 655
663 656 #[test]
664 657 fn repo_info_reads_description() {
665 - let (tmp, bare_path) = make_test_repo();
658 + let (_tmp, bare_path) = make_test_repo();
666 659 // Write a custom description
667 660 std::fs::write(bare_path.join("description"), "A test repository").unwrap();
668 661
669 - let repo = open_repo(tmp.path(), "owner", "testrepo").unwrap();
662 + let repo = open_gix(&bare_path);
670 663 let info = repo_info(&repo, "testrepo");
671 664 assert_eq!(info.name, "testrepo");
672 665 assert_eq!(info.description.as_deref(), Some("A test repository"));
@@ -675,8 +668,8 @@
675 668
676 669 #[test]
677 670 fn repo_info_ignores_default_description() {
678 - let (tmp, _) = make_test_repo();
679 - let repo = open_repo(tmp.path(), "owner", "testrepo").unwrap();
671 + let (_tmp, bare_path) = make_test_repo();
672 + let repo = open_gix(&bare_path);
680 673 let info = repo_info(&repo, "testrepo");
681 674 assert!(info.description.is_none());
682 675 }
@@ -747,9 +740,9 @@
747 740
748 741 #[test]
749 742 fn file_commit_log_filters_by_path() {
750 - let (tmp, bare_path) = make_two_commit_repo();
751 - let repo = open_repo(tmp.path(), "owner", "testrepo").unwrap();
752 - let oid = to_git2_oid(resolve_ref(&open_gix(&bare_path), "main").unwrap());
743 + let (_tmp, bare_path) = make_two_commit_repo();
744 + let repo = open_gix(&bare_path);
745 + let oid = resolve_ref(&repo, "main").unwrap();
753 746
754 747 // src/main.rs was changed in both commits
755 748 let main_rs_log = file_commit_log(&repo, oid, "src/main.rs", 10, 0, 1000).unwrap();
@@ -778,11 +771,122 @@
778 771 );
779 772 }
780 773
774 + #[test]
775 + fn commit_diff_reports_hunks_and_line_numbers() {
776 + let (_tmp, bare_path) = make_two_commit_repo();
777 + let repo = open_gix(&bare_path);
778 + let head = resolve_ref(&repo, "main").unwrap();
779 +
780 + let files = commit_diff(&repo, head, 10, 1000).unwrap();
781 + assert_eq!(
782 + files.len(),
783 + 1,
784 + "only src/main.rs changed in the second commit"
785 + );
786 +
787 + let file = &files[0];
788 + assert_eq!(file.path, "src/main.rs");
789 + assert!(matches!(file.status, DiffStatus::Modified));
790 + assert_eq!(file.additions, 1);
791 + assert_eq!(file.deletions, 1);
792 + assert!(!file.is_binary);
793 + assert!(!file.truncated);
794 +
795 + let hunk = &file.hunks[0];
796 + assert!(hunk.header.starts_with("@@"));
797 + let removed: Vec<_> = hunk.lines.iter().filter(|l| l.origin == '-').collect();
798 + let added: Vec<_> = hunk.lines.iter().filter(|l| l.origin == '+').collect();
799 + assert_eq!(removed[0].old_lineno, Some(1));
800 + assert_eq!(removed[0].new_lineno, None);
801 + assert_eq!(added[0].new_lineno, Some(1));
802 + assert_eq!(added[0].old_lineno, None);
803 + assert!(added[0].content.contains("println!"));
804 + }
805 +
806 + #[test]
807 + fn commit_diff_detects_a_rename() {
808 + // Rename tracking comes from gitoxide's diff configuration. libgit2 was
809 + // called without `find_similar`, so a rename used to render as an
810 + // addition plus a deletion and the Renamed status never appeared.
811 + let tmp = tempfile::TempDir::new().unwrap();
812 + let bare_path = tmp.path().join("owner").join("rename.git");
813 + std::fs::create_dir_all(&bare_path).unwrap();
814 + let bare = Repository::init_bare(&bare_path).unwrap();
815 + let sig = git2::Signature::now("Test", "test@example.com").unwrap();
816 +
817 + let body = b"one\ntwo\nthree\nfour\nfive\nsix\n";
818 + let blob = bare.blob(body).unwrap();
819 +
820 + let mut tb = bare.treebuilder(None).unwrap();
821 + tb.insert("before.txt", blob, 0o100_644).unwrap();
822 + let tree = bare.find_tree(tb.write().unwrap()).unwrap();
823 + let first = bare
824 + .commit(Some("refs/heads/main"), &sig, &sig, "add", &tree, &[])
825 + .unwrap();
826 + bare.set_head("refs/heads/main").unwrap();
827 +
828 + // Same blob, new name.
829 + let mut tb2 = bare.treebuilder(None).unwrap();
830 + tb2.insert("after.txt", blob, 0o100_644).unwrap();
831 + let tree2 = bare.find_tree(tb2.write().unwrap()).unwrap();
832 + let parent = bare.find_commit(first).unwrap();
833 + bare.commit(
834 + Some("refs/heads/main"),
835 + &sig,
836 + &sig,
837 + "rename",
838 + &tree2,
839 + &[&parent],
840 + )
841 + .unwrap();
842 +
843 + let repo = open_gix(&bare_path);
844 + let head = resolve_ref(&repo, "main").unwrap();
845 + let files = commit_diff(&repo, head, 10, 1000).unwrap();
846 +
847 + assert_eq!(
848 + files.len(),
849 + 1,
850 + "a rename is one entry, not an add and a delete"
851 + );
852 + assert!(matches!(files[0].status, DiffStatus::Renamed));
853 + assert_eq!(files[0].path, "after.txt");
854 + assert_eq!(files[0].old_path.as_deref(), Some("before.txt"));
855 + }
856 +
857 + #[test]
858 + fn commit_diff_escapes_html_in_content() {
859 + // Diff lines are rendered into the commit page unescaped by the
860 + // template, so the escaping has to happen here.
861 + let tmp = tempfile::TempDir::new().unwrap();
862 + let bare_path = tmp.path().join("owner").join("escape.git");
863 + std::fs::create_dir_all(&bare_path).unwrap();
864 + let bare = Repository::init_bare(&bare_path).unwrap();
865 + let sig = git2::Signature::now("Test", "test@example.com").unwrap();
866 +
867 + let blob = bare.blob(b"<script>alert('x') && y</script>\n").unwrap();
868 + let mut tb = bare.treebuilder(None).unwrap();
869 + tb.insert("x.html", blob, 0o100_644).unwrap();
870 + let tree = bare.find_tree(tb.write().unwrap()).unwrap();
871 + bare.commit(Some("refs/heads/main"), &sig, &sig, "add", &tree, &[])
872 + .unwrap();
873 + bare.set_head("refs/heads/main").unwrap();
874 +
875 + let repo = open_gix(&bare_path);
876 + let head = resolve_ref(&repo, "main").unwrap();
877 + let files = commit_diff(&repo, head, 10, 1000).unwrap();
878 +
879 + let content = &files[0].hunks[0].lines[0].content;
880 + assert!(!content.contains("<script>"));
881 + assert!(content.contains("&lt;script&gt;"));
882 + assert!(content.contains("&amp;&amp;"));
883 + }
884 +
781 885 #[test]
782 886 fn file_commit_log_pagination() {
783 - let (tmp, bare_path) = make_two_commit_repo();
784 - let repo = open_repo(tmp.path(), "owner", "testrepo").unwrap();
785 - let oid = to_git2_oid(resolve_ref(&open_gix(&bare_path), "main").unwrap());
887 + let (_tmp, bare_path) = make_two_commit_repo();
888 + let repo = open_gix(&bare_path);
889 + let oid = resolve_ref(&repo, "main").unwrap();
786 890
787 891 // Get first commit only (limit=1)
788 892 let page1 = file_commit_log(&repo, oid, "src/main.rs", 1, 0, 1000).unwrap();
@@ -48,8 +48,8 @@
48 48 .await?;
49 49 let repo_name_c = repo_name.clone();
50 50 let (info, refs, tree_items, readme_html) = resolved
51 - .with_repo(move |repo, gix_repo| {
52 - let info = git::repo_info(repo, &repo_name_c);
51 + .with_repo(move |gix_repo| {
52 + let info = git::repo_info(gix_repo, &repo_name_c);
53 53 let refs = git::list_refs(gix_repo);
54 54 let commit_oid = git::resolve_ref(gix_repo, &info.default_branch)?;
55 55 let tree_items = git::list_tree(gix_repo, commit_oid, "")?;
@@ -111,7 +111,7 @@
111 111 .await?;
112 112 let git_ref_c = git_ref.clone();
113 113 let (refs, tree_items, readme_html) = resolved
114 - .with_repo(move |_repo, gix_repo| {
114 + .with_repo(move |gix_repo| {
115 115 let refs = git::list_refs(gix_repo);
116 116 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
117 117 let tree_items = git::list_tree(gix_repo, commit_oid, "")?;
@@ -186,7 +186,7 @@
186 186 let git_ref_c = git_ref.clone();
187 187 let path_c = path.clone();
188 188 let dir_or_file = resolved
189 - .with_repo(move |_repo, gix_repo| {
189 + .with_repo(move |gix_repo| {
190 190 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
191 191 match git::list_tree(gix_repo, commit_oid, &path_c) {
192 192 Ok(tree_items) => Ok(DirOrFile::Dir(tree_items, git::list_refs(gix_repo))),
@@ -300,10 +300,10 @@
300 300
301 301 let git_ref_c = git_ref.clone();
302 302 let (refs, commits) = resolved
303 - .with_repo(move |repo, gix_repo| {
303 + .with_repo(move |gix_repo| {
304 304 let refs = git::list_refs(gix_repo);
305 305 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
306 - let commits = git::commit_log(repo, git::to_git2_oid(commit_oid), limit + 1, offset)?;
306 + let commits = git::commit_log(gix_repo, commit_oid, limit + 1, offset)?;
307 307 Ok((refs, commits))
308 308 })
309 309 .await?;
@@ -351,20 +351,20 @@
351 351 )
352 352 .await?;
353 353
354 - let oid = git2::Oid::from_str(&oid_str).map_err(|_| AppError::NotFound)?;
354 + let oid = gix::ObjectId::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?;
355 355 let repo_name_c = repo_name.clone();
356 356 let (detail, diff_files, refs, info) = resolved
357 - .with_repo(move |repo, gix_repo| {
358 - repo.find_commit(oid).map_err(|_| AppError::NotFound)?;
359 - let detail = git::commit_detail(repo, oid)?;
357 + .with_repo(move |gix_repo| {
358 + gix_repo.find_commit(oid).map_err(|_| AppError::NotFound)?;
359 + let detail = git::commit_detail(gix_repo, oid)?;
360 360 let diff_files = git::commit_diff(
361 - repo,
361 + gix_repo,
362 362 oid,
363 363 constants::GIT_DIFF_MAX_FILES,
364 364 constants::GIT_DIFF_MAX_LINES,
365 365 )?;
366 366 let refs = git::list_refs(gix_repo);
367 - let info = git::repo_info(repo, &repo_name_c);
367 + let info = git::repo_info(gix_repo, &repo_name_c);
368 368 Ok((detail, diff_files, refs, info))
369 369 })
370 370 .await?;
@@ -417,9 +417,9 @@
417 417 let git_ref_c = git_ref.clone();
418 418 let path_c = path.clone();
419 419 let (blame_lines, refs) = resolved
420 - .with_repo(move |repo, gix_repo| {
420 + .with_repo(move |gix_repo| {
421 421 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
422 - let blame_lines = git::blame_file(repo, git::to_git2_oid(commit_oid), &path_c)?;
422 + let blame_lines = git::blame_file(gix_repo, commit_oid, &path_c)?;
423 423 let refs = git::list_refs(gix_repo);
424 424 Ok((blame_lines, refs))
425 425 })
@@ -544,12 +544,12 @@
544 544 let git_ref_c = git_ref.clone();
545 545 let path_c = path.clone();
546 546 let (refs, commits) = resolved
547 - .with_repo(move |repo, gix_repo| {
547 + .with_repo(move |gix_repo| {
548 548 let refs = git::list_refs(gix_repo);
549 549 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
550 550 let commits = git::file_commit_log(
551 - repo,
552 - git::to_git2_oid(commit_oid),
551 + gix_repo,
552 + commit_oid,
553 553 &path_c,
554 554 limit + 1,
555 555 offset,
@@ -149,25 +149,18 @@
149 149 }
150 150
151 151 impl ResolvedRepo {
152 - /// Run a synchronous git closure on the blocking pool against freshly opened
153 - /// handles. `git2::Repository` is `!Send`, so the handle is reopened inside
154 - /// the closure rather than moved across the await. Keeps the disk + zlib
155 - /// work off the Tokio worker threads (Run #14 Perf LOW).
156 - ///
157 - /// Both handles are passed while the port from libgit2 to gitoxide is in
158 - /// flight: ported reads take the `gix` handle, the rest still take the
159 - /// `git2` one. The `git2` parameter goes away with the last git2 read path,
160 - /// and the reopen itself can go with it, since `gix::Repository` is `Send`.
152 + /// Run a synchronous git closure on the blocking pool against a freshly
153 + /// opened handle. Keeps the disk + zlib work off the Tokio worker threads
154 + /// (Run #14 Perf LOW).
161 155 pub(crate) async fn with_repo<T, F>(&self, f: F) -> Result<T>
162 156 where
163 157 T: Send + 'static,
164 - F: FnOnce(&git2::Repository, &gix::Repository) -> Result<T> + Send + 'static,
158 + F: FnOnce(&gix::Repository) -> Result<T> + Send + 'static,
165 159 {
166 160 let path = self.repo_path.clone();
167 161 tokio::task::spawn_blocking(move || {
168 - let repo = git::open_repo_at(&path)?;
169 - let gix_repo = git::open_gix_repo_at(&path)?;
170 - f(&repo, &gix_repo)
162 + let repo = git::open_gix_repo_at(&path)?;
163 + f(&repo)
171 164 })
172 165 .await
173 166 .map_err(|e| AppError::Internal(anyhow::anyhow!("git worker task failed: {e}")))?
@@ -42,7 +42,7 @@
42 42 let git_ref_c = git_ref.clone();
43 43 let path_c = path.clone();
44 44 let content: Vec<u8> = resolved
45 - .with_repo(move |_repo, gix_repo| {
45 + .with_repo(move |gix_repo| {
46 46 let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?;
47 47 let commit = gix_repo
48 48 .find_commit(commit_oid)
@@ -53,15 +53,15 @@
53 53 };
54 54 let owner = owner.to_string();
55 55 let repo_name = repo_name.to_string();
56 - // libgit2 open + repo_info are blocking filesystem work; run them on the
57 - // blocking pool so a nav-bar ref lookup can't stall a worker thread
56 + // Opening the repo and reading its info is blocking filesystem work; run it
57 + // on the blocking pool so a nav-bar ref lookup can't stall a worker thread
58 58 // (ultra-fuzz Run 10 Perf S4).
59 - tokio::task::spawn_blocking(
60 - move || match crate::git::open_repo(&root, &owner, &repo_name) {
59 + tokio::task::spawn_blocking(move || {
60 + match crate::git::open_gix_repo(&root, &owner, &repo_name) {
61 61 Ok(repo) => crate::git::repo_info(&repo, &repo_name).default_branch,
62 62 Err(_) => "main".to_string(),
63 - },
64 - )
63 + }
64 + })
65 65 .await
66 66 .unwrap_or_else(|_| "main".to_string())
67 67 }