Parent [>]
1//! Git repository helpers used across route handlers.
2//!
3//! Provides convenience functions for common `gix` operations such as opening a
4//! repository, formatting commit dates, validating paths, and iterating
5//! references.  The [`GitResultExt`] extension trait lets handlers convert gix
6//! errors into [`Error::GitCorrupt`] with a single `.corrupt()` call.
7
8use std::path::Path;
9
10use gix::bstr::ByteSlice;
11use jiff::Timestamp;
12use jiff::tz::{Offset, TimeZone};
13
14use crate::error::{Error, Result as CrateResult};
15
16/// Information about a tag for list views.
17pub struct TagInfo {
18    pub name: String,
19    pub date: String,
20    pub timestamp: i64,
21}
22
23/// Information about a branch for list views.
24pub struct BranchInfo {
25    pub name: String,
26    pub date: String,
27    pub timestamp: i64,
28    pub is_default: bool,
29}
30
31/// Summary of a commit for log views.
32pub struct CommitSummary {
33    pub sha: String,
34    pub message: String,
35    pub author: String,
36    pub date: String,
37}
38
39/// Annotation data for an annotated tag.
40pub struct TagAnnotation {
41    pub tagger: Option<String>,
42    pub date: Option<String>,
43    pub message: Option<String>,
44}
45
46/// Basic commit identity info (sha + author + date).
47pub struct CommitInfo {
48    pub sha: String,
49    pub author: String,
50    pub date: String,
51}
52
53/// Paginated commit log for a branch.
54pub struct BranchLog {
55    pub tip_sha: String,
56    pub commits: Vec<CommitSummary>,
57    pub next_cursor: Option<String>,
58}
59
60/// A single entry in blame output.
61pub struct BlameEntry {
62    pub sha: String,
63    pub short_sha: String,
64    pub color: String,
65    pub num: usize,
66    pub content: String,
67}
68
69/// A single commit entry for the full ancestry graph view.
70pub struct CommitEntry {
71    pub sha: String,
72    pub message: String,
73    pub date: String,
74    pub parents: Vec<String>,
75}
76
77/// Opens a repository at `root/{name}` or returns [`Error::RepoNotFound`].
78pub fn open_repo(root: &Path, name: &str) -> CrateResult<gix::Repository> {
79    gix::open(root.join(name)).map_err(|_| Error::RepoNotFound(name.to_owned()))
80}
81
82/// Rejects paths containing non-normal [`Component`]s (e.g. `..`) to prevent
83/// directory traversal.
84pub fn validate_path(path: &str) -> CrateResult<()> {
85    if std::path::Path::new(path)
86        .components()
87        .any(|c| !matches!(c, std::path::Component::Normal(_)))
88    {
89        return Err(Error::BadRequest("invalid path".to_string()));
90    }
91    Ok(())
92}
93
94/// Returns the commit's author timestamp as Unix seconds.
95pub fn commit_timestamp(commit: &gix::Commit) -> Option<i64> {
96    commit.time().ok().map(|t| t.seconds)
97}
98
99/// Formats a commit's author date as a `YYYY-MM-DD` string in the commit's
100/// local timezone, returning `None` if the time cannot be parsed.
101pub fn commit_date(commit: &gix::Commit) -> Option<String> {
102    let time = commit.time().ok()?;
103    format_time(time.seconds, time.offset)
104}
105
106/// Formats a commit's committer date as a `YYYY-MM-DD` string in the
107/// commit's local timezone, returning `None` if the time cannot be parsed.
108pub fn committer_date(commit: &gix::Commit) -> Option<String> {
109    let sig = commit.committer().ok()?;
110    let time = sig.time().ok()?;
111    format_time(time.seconds, time.offset)
112}
113
114/// Converts a Unix timestamp + timezone offset into a `YYYY-MM-DD` string.
115fn format_time(seconds: i64, offset: i32) -> Option<String> {
116    let zone = TimeZone::fixed(Offset::from_seconds(offset).ok()?);
117    Timestamp::from_second(seconds)
118        .map(|ts| ts.to_zoned(zone).date().to_string())
119        .ok()
120}
121
122/// Extension trait adding `.corrupt()` to `Result<T, E>` where `E: Display`.
123///
124/// Converts an error into [`Error::GitCorrupt`] by calling `.to_string()` on
125/// the inner error value.  This removes the need for repetitive
126/// `.map_err(|e| Error::GitCorrupt(e.to_string()))` chains.
127///
128/// # Usage
129///
130/// ```ignore
131/// use crate::git::GitResultExt as _;
132/// let repo = git_repo.references().corrupt()?;
133/// ```
134pub trait GitResultExt<T> {
135    fn corrupt(self) -> CrateResult<T>;
136}
137
138impl<T, E: std::fmt::Display> GitResultExt<T> for std::result::Result<T, E> {
139    fn corrupt(self) -> CrateResult<T> {
140        self.map_err(|e| Error::GitCorrupt(e.to_string()))
141    }
142}
143
144/// Silently flattens a `Result<impl IntoIterator<Item = Result<T, E2>>, E1>` into
145/// an `Iterator<Item = T>`.
146///
147/// Both the outer and inner `Result` layers are consumed via `.into_iter()` and
148/// `.flatten()`, so errors at either level are silently skipped.  This matches
149/// the common git-ref iteration pattern where a single corrupt ref should not
150/// crash the page.
151pub fn flatten_refs<T, E1, E2, I>(result: std::result::Result<I, E1>) -> impl Iterator<Item = T>
152where
153    I: IntoIterator<Item = std::result::Result<T, E2>>,
154{
155    result.into_iter().flatten().flatten()
156}
157
158/// Returns the total number of commits reachable from any ref in the
159/// repository.
160///
161/// Collects all ref tips (branches, tags, remote-tracking refs, and detached
162/// HEAD), then walks the complete reachable commit graph using gix's
163/// commit-graph-aware traversal.  This is consistent regardless of
164/// commit-graph availability.
165pub fn commit_count(repo: &gix::Repository) -> usize {
166    let refs = match repo.references() {
167        Ok(r) => r,
168        Err(_) => return 0,
169    };
170
171    let mut tips = Vec::new();
172
173    for mut reference in flatten_refs(refs.all()) {
174        if let Ok(commit) = reference.peel_to_commit() {
175            tips.push(commit.id().detach());
176        }
177    }
178
179    // Include HEAD in case it is detached (not covered by refs.all()).
180    if let Ok(head_id) = repo.head_id()
181        && let Ok(obj) = head_id.object()
182        && let Ok(commit) = obj.try_into_commit()
183    {
184        tips.push(commit.id().detach());
185    }
186
187    tips.sort();
188    tips.dedup();
189
190    if tips.is_empty() {
191        return 0;
192    }
193
194    repo.rev_walk(tips)
195        .all()
196        .map(|walk| walk.filter_map(Result::ok).count())
197        .unwrap_or(0)
198}
199
200/// Loads all tags from a repository, sorted newest-first.
201/// Errors during iteration are silently skipped.
202pub fn load_tags(repo: &gix::Repository) -> Vec<TagInfo> {
203    let refs = match repo.references() {
204        Ok(r) => r,
205        Err(_) => return Vec::new(),
206    };
207    let mut tags: Vec<TagInfo> = flatten_refs(refs.tags())
208        .filter_map(|mut tag| {
209            let commit = tag.peel_to_commit().ok()?;
210            let date = commit_date(&commit)?;
211            let timestamp = commit_timestamp(&commit)?;
212            let name = tag.name().shorten().to_string();
213            Some(TagInfo { name, date, timestamp })
214        })
215        .collect();
216    tags.sort_by_key(|t| std::cmp::Reverse(t.timestamp));
217    tags
218}
219
220/// Loads a single tag's annotation and its target commit.
221pub fn load_tag(repo: &gix::Repository, tag_name: &str) -> CrateResult<(TagAnnotation, CommitInfo)> {
222    let ref_name = format!("refs/tags/{tag_name}");
223    let mut reference = repo
224        .find_reference(&ref_name)
225        .map_err(|_| Error::NotFound(format!("tag {tag_name}")))?;
226
227    let (tagger, tag_date, message) = reference
228        .peel_to_tag()
229        .ok()
230        .and_then(|t| {
231            let decoded = t.decode().ok()?;
232            let sig = decoded.tagger().ok().flatten();
233            let tagger = sig.as_ref().map(|s| s.name.to_str_lossy().into_owned());
234            let tag_date = sig.as_ref().and_then(|s| {
235                let time = s.time().ok()?;
236                format_time(time.seconds, time.offset)
237            });
238            let msg = decoded.message.to_str_lossy().trim().to_string();
239            let message = if msg.is_empty() { None } else { Some(msg) };
240            Some((tagger, tag_date, message))
241        })
242        .unwrap_or((None, None, None));
243
244    let commit = reference.peel_to_commit().corrupt()?;
245    let sha = commit.id().to_string();
246    let author = commit.author().map(|a| a.name.to_string()).unwrap_or_default();
247    let date = commit_date(&commit).unwrap_or_default();
248
249    Ok((TagAnnotation { tagger, date: tag_date, message }, CommitInfo { sha, author, date }))
250}
251
252/// Loads all local branches from a repository, sorted with the default branch
253/// first, then by most recent commit. Errors during iteration are silently skipped.
254pub fn load_branches(repo: &gix::Repository) -> Vec<BranchInfo> {
255    let refs = match repo.references() {
256        Ok(r) => r,
257        Err(_) => return Vec::new(),
258    };
259    let head_name = repo.head_name().ok().flatten();
260
261    let mut branches: Vec<BranchInfo> = flatten_refs(refs.local_branches())
262        .filter_map(|mut branch| {
263            let commit = branch.peel_to_commit().ok()?;
264            let date = commit_date(&commit)?;
265            let timestamp = commit_timestamp(&commit)?;
266            let name = branch.name().shorten().to_string();
267            let is_default = head_name.as_ref().is_some_and(|h| h.as_ref() == branch.name());
268            Some(BranchInfo { name, date, timestamp, is_default })
269        })
270        .collect();
271
272    branches.sort_by(|a, b| {
273        b.is_default.cmp(&a.is_default).then(b.timestamp.cmp(&a.timestamp))
274    });
275    branches
276}
277
278/// Loads a paginated log of commits reachable from the tip of a branch.
279pub fn load_branch_log(
280    repo: &gix::Repository,
281    branch_name: &str,
282    after: Option<String>,
283    page_size: usize,
284) -> CrateResult<BranchLog> {
285    let ref_name = format!("refs/heads/{branch_name}");
286    let tip = repo
287        .find_reference(&ref_name)
288        .map_err(|_| Error::NotFound(format!("branch {branch_name}")))?
289        .peel_to_commit()
290        .corrupt()?;
291
292    let tip_sha = tip.id().to_string();
293
294    let start_id = match after {
295        Some(ref sha) => {
296            let oid = gix::ObjectId::from_hex(sha.as_bytes())
297                .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
298            repo.find_object(oid)
299                .map_err(|_| Error::NotFound(format!("commit {sha}")))?
300                .id()
301        }
302        None => tip.id(),
303    };
304
305    let skip_count = if after.is_some() { 1 } else { 0 };
306
307    let mut commits: Vec<CommitSummary> = start_id
308        .ancestors()
309        .all()
310        .corrupt()?
311        .skip(skip_count)
312        .filter_map(|info| {
313            let info = info.ok()?;
314            let commit = info.id().object().ok()?.try_into_commit().ok()?;
315            Some(CommitSummary {
316                sha: info.id().to_string(),
317                message: commit.message().ok()?.summary().to_string(),
318                author: commit.author().ok()?.name.to_string(),
319                date: commit_date(&commit)?,
320            })
321        })
322        .take(page_size + 1)
323        .collect();
324
325    let has_next = commits.len() > page_size;
326    if has_next {
327        commits.pop();
328    }
329
330    let next_cursor = if has_next { commits.last().map(|c| c.sha.clone()) } else { None };
331
332    Ok(BranchLog { tip_sha, commits, next_cursor })
333}
334
335/// Color palette for commit highlighting (matches commit.rs graph colors).
336const COLORS: &[&str] = &[
337    "var(--blue)", "var(--red)", "var(--green)", "var(--yellow)",
338    "var(--violet)", "var(--magenta)", "var(--cyan)", "var(--orange)",
339];
340
341#[allow(clippy::cast_possible_truncation)]
342fn commit_color(sha: &str) -> &'static str {
343    let hash: u64 = sha.bytes().fold(0u64, |acc, b| {
344        acc.wrapping_mul(31).wrapping_add(u64::from(b))
345    });
346    COLORS[hash as usize % COLORS.len()]
347}
348
349/// Loads blame annotations for a file at `{sha}:{path}`.
350pub fn load_blame(repo: &gix::Repository, sha: &str, path: &str) -> CrateResult<Vec<BlameEntry>> {
351    let oid = gix::ObjectId::from_hex(sha.as_bytes())
352        .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
353
354    let _commit = repo
355        .find_object(oid)
356        .map_err(|_| Error::NotFound(sha.to_string()))?
357        .try_into_commit()
358        .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?;
359
360    let file_path = gix::bstr::BStr::new(path.as_bytes());
361    let opts = gix::repository::blame_file::Options::default();
362    let outcome = repo.blame_file(file_path, oid, opts).corrupt()?;
363
364    let content = String::from_utf8_lossy(&outcome.blob).into_owned();
365    let file_lines: Vec<&str> = content.split('\n').collect();
366
367    let mut entries = Vec::new();
368    for entry in &outcome.entries {
369        let sha = entry.commit_id.to_string();
370        let color = commit_color(&sha);
371        let short_sha = sha[..8].to_string();
372        for i in 0..entry.len.get() {
373            let line_idx = (entry.start_in_blamed_file + i) as usize;
374            let content = file_lines.get(line_idx).unwrap_or(&"").to_string();
375            entries.push(BlameEntry {
376                sha: sha.clone(),
377                short_sha: short_sha.clone(),
378                color: color.to_string(),
379                num: line_idx + 1,
380                content,
381            });
382        }
383    }
384
385    Ok(entries)
386}
387
388/// Loads up to `limit` commits starting from `after` (inclusive) or from
389/// HEAD when `after` is `None`. Returns an empty vec if the repo has no commits.
390pub fn load_commits(
391    repo: &gix::Repository,
392    after: Option<&str>,
393    limit: usize,
394) -> CrateResult<Vec<CommitEntry>> {
395    let start_id = match after {
396        Some(sha) => {
397            let oid = gix::ObjectId::from_hex(sha.as_bytes())
398                .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
399            let obj = repo
400                .find_object(oid)
401                .map_err(|_| Error::NotFound(format!("commit {sha}")))?;
402            obj.id()
403        }
404        None => match repo.head_commit() {
405            Ok(head) => head.id(),
406            Err(_) => return Ok(Vec::new()),
407        },
408    };
409
410    let entries = start_id
411        .ancestors()
412        .all()
413        .corrupt()?
414        .filter_map(|info| {
415            let info = info.ok()?;
416            let commit = info.id().object().ok()?.try_into_commit().ok()?;
417            Some(CommitEntry {
418                sha: info.id().to_string(),
419                message: commit.message().ok()?.summary().to_string(),
420                date: commit_date(&commit).unwrap_or_default(),
421                parents: commit.parent_ids().map(|id| id.to_string()).collect(),
422            })
423        })
424        .take(limit)
425        .collect();
426
427    Ok(entries)
428}