Parent [>]
1//! Commit log list and detail pages.
2//!
3//! Covers two routes: the commit log (`/{repo}/commits`) and the commit detail
4//! page (`/{repo}/commits/{sha}`).  The `list` handler renders a paginated
5//! commit log with an SVG ancestry graph and inline ref indicators.  The
6//! `detail` handler renders a single commit view with full metadata, a diff
7//! stat, and per-file inline diffs via `gix::diff::blob::UnifiedDiff`.
8
9use std::collections::HashMap;
10use std::ops::ControlFlow;
11
12use askama::Template;
13use askama_web::WebTemplate;
14use axum::extract::{Path, Query, State};
15use axum::response::IntoResponse;
16use gix::bstr::ByteSlice;
17use serde::Deserialize;
18
19use crate::error::{Error, Result};
20use crate::routes::{AppState, RepoName, commit_date, committer_date};
21
22use gix::diff::blob::UnifiedDiff;
23use gix::diff::blob::platform::prepare_diff::Operation;
24use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader};
25
26/// Number of commits per page in the log view.
27const PAGE_SIZE: usize = 50;
28
29// SVG graph layout constants.
30// `ROW_H` is the per-row height, `LANE_W` is the per-lane width (spacing
31// between parallel tracks), `NODE_R` is the dot radius, and `BEND_R` is
32// the corner radius used when an edge changes lanes.
33const ROW_H: f64 = 40.0;
34const LANE_W: f64 = 20.0;
35const NODE_R: f64 = 5.0;
36const OUTER_R: f64 = ROW_H / 2.0;
37const STROKE: f64 = 2.0;
38const Y_MID: f64 = ROW_H / 2.0;
39const BEND_R: f64 = 6.0;
40
41/// Color palette used for SVG graph lanes.
42/// Each lane gets one color from this cycle.
43const COLORS: &[&str] = &[
44    "var(--blue)",
45    "var(--red)",
46    "var(--green)",
47    "var(--yellow)",
48    "var(--violet)",
49    "var(--magenta)",
50    "var(--cyan)",
51    "var(--orange)",
52];
53
54/// A single commit entry in the commit log.
55struct CommitEntry {
56    sha: String,
57    message: String,
58    date: String,
59    parents: Vec<String>,
60}
61
62/// Loads up to `limit` commits from `HEAD`, walking ancestors.
63fn load_commits(git_repo: &gix::Repository, limit: usize) -> Result<Vec<CommitEntry>> {
64    let head = git_repo
65        .head_commit()
66        .map_err(|e| Error::GitCorrupt(e.to_string()))?;
67
68    let entries = head
69        .id()
70        .ancestors()
71        .all()
72        .map_err(|e| Error::GitCorrupt(e.to_string()))?
73        .filter_map(|info| {
74            let info = info.ok()?;
75            let commit = info.id().object().ok()?.try_into_commit().ok()?;
76
77            Some(CommitEntry {
78                sha: info.id().to_string(),
79                message: commit.message().ok()?.summary().to_string(),
80                date: commit_date(&commit).unwrap_or_default(),
81                parents: commit.parent_ids().map(|id| id.to_string()).collect(),
82            })
83        })
84        .take(limit)
85        .collect();
86
87    Ok(entries)
88}
89
90/// (row, column) positions for every commit in the current page, plus a
91/// snapshot of active lanes entering the page from above so the SVG renderer
92/// can draw continuation edges.
93struct Layout {
94    positions: HashMap<String, (usize, usize)>,
95    active_at_page_start: Vec<Option<String>>,
96}
97
98/// Assigns each commit a (row, column) position for the SVG graph.
99///
100/// Uses a column-stealing algorithm: a commit claims the column its first
101/// parent occupies, freeing the previous column for other branches. Lane
102/// re-use keeps the graph narrow.
103fn compute_layout(commits: &[CommitEntry], page_start: usize) -> Layout {
104    let mut active: Vec<Option<&str>> = vec![];
105    let mut positions = HashMap::new();
106    let mut active_at_page_start = vec![];
107
108    for (row, commit) in commits.iter().enumerate() {
109        if row == page_start {
110            active_at_page_start = active
111                .iter()
112                .map(|opt| opt.map(|s| s.to_string()))
113                .collect();
114        }
115
116        let sha = commit.sha.as_str();
117        let col = match active.iter().position(|s| *s == Some(sha)) {
118            Some(i) => i,
119            None => match active.iter().position(|s| s.is_none()) {
120                Some(i) => {
121                    active[i] = Some(sha);
122                    i
123                }
124                None => {
125                    active.push(Some(sha));
126                    active.len() - 1
127                }
128            },
129        };
130
131        positions.insert(commit.sha.clone(), (row, col));
132
133        if commit.parents.is_empty() {
134            active[col] = None;
135            while matches!(active.last(), Some(None)) {
136                active.pop();
137            }
138        } else {
139            let first = commit.parents[0].as_str();
140            if active.iter().position(|s| *s == Some(first)) != Some(col) {
141                active[col] = None;
142                while matches!(active.last(), Some(None)) {
143                    active.pop();
144                }
145                if active.iter().all(|s| *s != Some(first)) {
146                    match active.iter().position(|s| s.is_none()) {
147                        Some(i) => active[i] = Some(first),
148                        None => active.push(Some(first)),
149                    }
150                }
151            }
152
153            for parent in commit.parents.iter().skip(1) {
154                if active.iter().any(|s| *s == Some(parent.as_str())) {
155                    continue;
156                }
157                match active.iter().position(|s| s.is_none()) {
158                    Some(i) => active[i] = Some(parent),
159                    None => active.push(Some(parent)),
160                }
161            }
162        }
163    }
164
165    Layout {
166        positions,
167        active_at_page_start,
168    }
169}
170
171/// Renders the SVG ancestry graph for commits[page_start..page_end].
172///
173/// Three passes:
174/// 1. Continuation edges entering from above the viewport.
175/// 2. Edges from each visible commit to its parents (straight lines for
176///    same-column, L-shaped bends for lane changes, S-shaped curves for
177///    merge arrows).
178/// 3. Node circles and lane-highlight backgrounds on top.
179fn render_page_svg(
180    commits: &[CommitEntry],
181    layout: &Layout,
182    page_start: usize,
183    page_end: usize,
184) -> String {
185    if page_start >= page_end {
186        return String::new();
187    }
188
189    let mut all_cols: Vec<usize> = Vec::new();
190    for c in &commits[page_start..page_end] {
191        if let Some(&(_, col)) = layout.positions.get(&c.sha) {
192            all_cols.push(col);
193        }
194    }
195
196    let min_col = all_cols.iter().copied().min().unwrap_or(0);
197    let page_max_col = all_cols.iter().copied().max().unwrap_or(0);
198    let clamp_col = |c: usize| c.max(min_col).min(page_max_col);
199
200    let svg_h = (page_end - page_start) as f64 * ROW_H;
201    let svg_w = (page_max_col - min_col + 1) as f64 * LANE_W + OUTER_R;
202
203    let lx = |col: usize| -> f64 { (col - min_col) as f64 * LANE_W + OUTER_R };
204    let ry =
205        |abs_row: usize| -> f64 { (abs_row.saturating_sub(page_start)) as f64 * ROW_H + Y_MID };
206
207    let mut body = String::new();
208
209    // Pass 1: continuation edges entering from above.
210    for (col, opt_sha) in layout.active_at_page_start.iter().enumerate() {
211        let Some(sha) = opt_sha else { continue };
212        let Some(&(abs_row, _)) = layout.positions.get(sha.as_str()) else {
213            continue;
214        };
215        let x = lx(clamp_col(col));
216        let c = COLORS[col % COLORS.len()];
217        let y_to = if abs_row < page_start {
218            0.0
219        } else if abs_row < page_end {
220            ry(abs_row)
221        } else {
222            svg_h
223        };
224
225        body.push_str(&format!(
226            r#"<line x1="{x:.1}" y1="0.0" x2="{x:.1}" y2="{y_to:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
227        ));
228    }
229
230    // Pass 2: edges originating from commits on this page.
231    for commit in &commits[page_start..page_end] {
232        let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();
233        let x1 = lx(col);
234        let y1 = ry(abs_row);
235        let c = COLORS[col % COLORS.len()];
236
237        for (idx, parent_sha) in commit.parents.iter().enumerate() {
238            let (x2, y2, p_col) = match layout.positions.get(parent_sha.as_str()) {
239                Some(&(p_row, p_col)) if p_row >= page_start && p_row < page_end => {
240                    (lx(p_col), ry(p_row), p_col)
241                }
242                Some(&(p_row, p_col)) => (
243                    lx(clamp_col(p_col)),
244                    if p_row < page_start { 0.0 } else { svg_h },
245                    p_col,
246                ),
247                None => {
248                    body.push_str(&format!(
249                        r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x1:.1}" y2="{svg_h:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
250                    ));
251                    continue;
252                }
253            };
254
255            if col == p_col {
256                body.push_str(&format!(
257                    r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
258                ));
259            } else if idx == 0 {
260                let r = BEND_R.min((x2 - x1).abs());
261                let (arc_x, sweep) = if x2 < x1 { (x1 - r, 1) } else { (x1 + r, 0) };
262
263                body.push_str(&format!(
264                    r#"<path d="M {x1:.1} {y1:.1} L {x1:.1} {:.1} A {r:.1} {r:.1} 0 0 {sweep} {arc_x:.1} {y2:.1} L {x2:.1} {y2:.1}" stroke="{c}" stroke-width="{STROKE}" fill="none"/>"#,
265                    y2 - r,
266                ));
267            } else {
268                let pc = COLORS[p_col % COLORS.len()];
269                let r = BEND_R.min((x2 - x1).abs());
270                let (arc_x, sweep) = if x2 > x1 { (x2 - r, 1) } else { (x2 + r, 0) };
271
272                body.push_str(&format!(
273                    r#"<path d="M {x1:.1} {y1:.1} L {arc_x:.1} {y1:.1} A {r:.1} {r:.1} 0 0 {sweep} {x2:.1} {:.1} L {x2:.1} {y2:.1}" stroke="{pc}" stroke-width="{STROKE}" fill="none"/>"#,
274                    y1 + r,
275                ));
276            }
277        }
278    }
279
280    // Pass 3: circles on top.
281    for commit in &commits[page_start..page_end] {
282        let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();
283        let cx = lx(col);
284        let cy = ry(abs_row);
285        let c = COLORS[col % COLORS.len()];
286        let row_top = cy - Y_MID;
287        let rect_x = cx - OUTER_R;
288
289        body.push_str(&format!(
290            r#"<path d="M {rect_x:.1} {top:.1} A {inner:.1} {inner:.1} 0 0 1 {right:.1} {row_top:.1} L {svg_w:.1} {row_top:.1} L {svg_w:.1} {bot:.1} L {right:.1} {bot:.1} A {inner:.1} {inner:.1} 0 0 1 {rect_x:.1} {bot_sub:.1} Z" fill="{c}" opacity="0.10"/>"#,
291            top = row_top + OUTER_R,
292            inner = OUTER_R,
293            right = rect_x + OUTER_R,
294            bot = row_top + ROW_H,
295            bot_sub = row_top + ROW_H - OUTER_R,
296        ));
297
298        body.push_str(&format!(
299            r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="{NODE_R}" fill="{c}"/>"#
300        ));
301    }
302
303    format!(
304        r#"<svg width="{svg_w:.1}" height="{svg_h:.1}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">{body}</svg>"#
305    )
306}
307
308/// Query parameters for the commit log page.
309#[derive(Deserialize, Default)]
310pub struct PageQuery {
311    /// Zero-based page index.
312    #[serde(default)]
313    page: usize,
314}
315
316/// A branch or tag ref pointing at a commit.
317struct RefLabel {
318    name: String,
319    kind: String,
320}
321
322/// A single row of the commit log, ready for the template.
323struct CommitRow {
324    sha: String,
325    message: String,
326    date: String,
327    color: String,
328    refs: Vec<RefLabel>,
329}
330
331/// Template data for the paginated commit log page.
332#[derive(Template, WebTemplate)]
333#[template(path = "commit_list.html")]
334struct CommitList {
335    repo: String,
336    graph_svg: String,
337    commits: Vec<CommitRow>,
338    page: usize,
339    has_prev: bool,
340    has_next: bool,
341}
342
343/// Renders a paginated commit log with an SVG ancestry graph.
344pub async fn list(
345    State(state): State<AppState>,
346    RepoName(repo): RepoName,
347    Query(q): Query<PageQuery>,
348) -> Result<impl IntoResponse> {
349    let git_repo =
350        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;
351
352    let page_start = q.page * PAGE_SIZE;
353    let page_end = page_start + PAGE_SIZE;
354
355    let entries = load_commits(&git_repo, page_end + 1)?;
356    let has_next = entries.len() > page_end;
357    let visible_end = page_end.min(entries.len());
358
359    let layout = compute_layout(&entries, page_start);
360    let graph_svg = render_page_svg(&entries, &layout, page_start, visible_end);
361
362    let mut refs_by_sha: HashMap<String, Vec<RefLabel>> = HashMap::new();
363    if let Ok(refs) = git_repo.references() {
364        for mut branch in refs.local_branches().into_iter().flatten().flatten() {
365            if let Ok(commit) = branch.peel_to_commit() {
366                refs_by_sha
367                    .entry(commit.id().to_string())
368                    .or_default()
369                    .push(RefLabel {
370                        name: branch.name().shorten().to_string(),
371                        kind: "branch".into(),
372                    });
373            }
374        }
375
376        for mut tag in refs.tags().into_iter().flatten().flatten() {
377            if let Ok(commit) = tag.peel_to_commit() {
378                refs_by_sha
379                    .entry(commit.id().to_string())
380                    .or_default()
381                    .push(RefLabel {
382                        name: tag.name().shorten().to_string(),
383                        kind: "tag".into(),
384                    });
385            }
386        }
387    }
388
389    let commits = entries[page_start..visible_end]
390        .iter()
391        .map(|e| CommitRow {
392            color: layout
393                .positions
394                .get(&e.sha)
395                .map(|&(_, col)| COLORS[col % COLORS.len()])
396                .unwrap_or("")
397                .to_string(),
398            sha: e.sha.clone(),
399            message: e.message.clone(),
400            date: e.date.clone(),
401            refs: refs_by_sha.remove(e.sha.as_str()).unwrap_or_default(),
402        })
403        .collect();
404
405    Ok(CommitList {
406        repo,
407        graph_svg,
408        commits,
409        page: q.page,
410        has_prev: q.page > 0,
411        has_next,
412    })
413}
414
415/// Path parameter for the commit detail page.
416#[derive(Deserialize)]
417pub(super) struct CommitPath {
418    sha: String,
419}
420
421/// A single line inside a diff hunk, ready for the template.
422///
423/// `marker` is the visible prefix character (`+`, `-`, or `~`).
424/// `css_class` is the CSS class suffix used to color the marker.
425/// `content` is the rest of the line.
426struct DiffLine {
427    marker: String,
428    css_class: String,
429    content: String,
430}
431
432/// Collects hunks produced by `UnifiedDiff` into a `Vec<Vec<DiffLine>>`,
433/// counting added and removed lines at the same time.
434struct DiffHunkCollector<'a> {
435    hunks: &'a mut Vec<Vec<DiffLine>>,
436    file_added: &'a mut u64,
437    file_removed: &'a mut u64,
438}
439
440impl ConsumeHunk for DiffHunkCollector<'_> {
441    type Out = ();
442
443    fn consume_hunk(
444        &mut self,
445        _header: HunkHeader,
446        lines: &[(DiffLineKind, &[u8])],
447    ) -> std::io::Result<()> {
448        let mut hunk = Vec::new();
449        for (kind, content) in lines {
450            let content_str = std::str::from_utf8(content)
451                .unwrap_or("<binary>")
452                .to_string();
453
454            let (marker, css_class) = match kind {
455                DiffLineKind::Context => ("~".into(), "ctx".into()),
456                DiffLineKind::Add => ("+".into(), "+".into()),
457                DiffLineKind::Remove => ("-".into(), "-".into()),
458            };
459
460            if matches!(kind, DiffLineKind::Add) {
461                *self.file_added += 1;
462            } else if matches!(kind, DiffLineKind::Remove) {
463                *self.file_removed += 1;
464            }
465
466            hunk.push(DiffLine {
467                marker,
468                css_class,
469                content: content_str,
470            });
471        }
472
473        self.hunks.push(hunk);
474        Ok(())
475    }
476
477    fn finish(self) -> Self::Out {}
478}
479
480/// A single file touched by a commit, with its inline diff hunks.
481struct FileChange {
482    path: String,
483    change_type: String,
484    hunks: Vec<Vec<DiffLine>>,
485}
486
487/// Template data for the commit detail page.
488#[derive(Template, WebTemplate)]
489#[template(path = "commit.html")]
490struct CommitDetail {
491    repo: String,
492    sha: String,
493    author: String,
494    committer: String,
495    date: String,
496    committer_date: String,
497    message: String,
498    parents: Vec<(String, String)>,
499    refs: Vec<RefLabel>,
500    files_changed: u64,
501    lines_added: u64,
502    lines_removed: u64,
503    file_changes: Vec<FileChange>,
504}
505
506/// Displays a single commit: metadata, diff stat, and per-file inline diffs.
507pub async fn detail(
508    State(state): State<AppState>,
509    RepoName(repo): RepoName,
510    Path(params): Path<CommitPath>,
511) -> Result<impl IntoResponse> {
512    let sha = params.sha;
513
514    let git_repo =
515        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;
516
517    let oid = gix::ObjectId::from_hex(sha.as_bytes())
518        .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
519
520    let commit = git_repo
521        .find_object(oid)
522        .map_err(|_| Error::NotFound(format!("commit {sha}")))?
523        .try_into_commit()
524        .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?;
525
526    let sha = commit.id().to_string();
527
528    let author = commit
529        .author()
530        .map(|a| format!("{} <{}>", a.name, a.email))
531        .unwrap_or_default();
532
533    let committer = commit
534        .committer()
535        .map(|c| format!("{} <{}>", c.name, c.email))
536        .unwrap_or_default();
537
538    let date = commit_date(&commit).unwrap_or_default();
539    let committer_date = committer_date(&commit).unwrap_or_default();
540    let message = commit.message_raw_sloppy().to_str_lossy().into_owned();
541
542    let parents: Vec<(String, String)> = commit
543        .parent_ids()
544        .map(|id| {
545            let s = id.to_string();
546            let short = s[..8].to_string();
547            (s, short)
548        })
549        .collect();
550
551    // Collect refs (branches + tags) pointing to this commit.
552    let mut refs: Vec<RefLabel> = Vec::new();
553    if let Ok(rs) = git_repo.references() {
554        for mut branch in rs.local_branches().into_iter().flatten().flatten() {
555            if let Ok(c) = branch.peel_to_commit()
556                && c.id().to_string() == sha
557            {
558                refs.push(RefLabel {
559                    name: branch.name().shorten().to_string(),
560                    kind: "branch".into(),
561                });
562            }
563        }
564
565        for mut tag in rs.tags().into_iter().flatten().flatten() {
566            if let Ok(c) = tag.peel_to_commit()
567                && c.id().to_string() == sha
568            {
569                refs.push(RefLabel {
570                    name: tag.name().shorten().to_string(),
571                    kind: "tag".into(),
572                });
573            }
574        }
575    }
576
577    // Compute diff stat against the first parent (or empty tree for root commits).
578    let commit_tree = commit
579        .tree()
580        .map_err(|e| Error::GitCorrupt(e.to_string()))?;
581
582    let parent_tree = commit
583        .parent_ids()
584        .next()
585        .and_then(|pid| pid.object().ok())
586        .and_then(|o| o.try_into_commit().ok())
587        .and_then(|c| c.tree().ok());
588
589    let mut resource_cache = git_repo
590        .diff_resource_cache_for_tree_diff()
591        .map_err(|e| Error::GitCorrupt(e.to_string()))?;
592
593    let mut files_changed = 0u64;
594    let mut lines_added = 0u64;
595    let mut lines_removed = 0u64;
596    let mut file_changes: Vec<FileChange> = Vec::new();
597
598    let source_tree = match parent_tree {
599        Some(ref t) => t,
600        None => &git_repo.empty_tree(),
601    };
602
603    source_tree
604        .changes()
605        .map_err(|e| Error::GitCorrupt(e.to_string()))?
606        .for_each_to_obtain_tree(&commit_tree, |change| {
607            // Skip directory entries - only show leaf-level file changes.
608            if change.entry_mode().is_tree() {
609                return Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()));
610            }
611
612            let path = change.location().to_str_lossy().into_owned();
613            let change_type = match change {
614                gix::object::tree::diff::Change::Addition { .. } => "A",
615                gix::object::tree::diff::Change::Deletion { .. } => "D",
616                gix::object::tree::diff::Change::Modification { .. } => "M",
617                gix::object::tree::diff::Change::Rewrite { .. } => "R",
618            };
619
620            files_changed += 1;
621
622            let mut hunks = Vec::new();
623            let mut added = 0u64;
624            let mut removed = 0u64;
625
626            if let Ok(platform) = change.diff(&mut resource_cache) {
627                platform
628                    .resource_cache
629                    .options
630                    .skip_internal_diff_if_external_is_configured = false;
631                if let Ok(prep) = platform.resource_cache.prepare_diff() {
632                    if let Operation::InternalDiff { algorithm } = prep.operation {
633                        let input = prep.interned_input();
634                        let collector = DiffHunkCollector {
635                            hunks: &mut hunks,
636                            file_added: &mut added,
637                            file_removed: &mut removed,
638                        };
639                        let sink = UnifiedDiff::new(&input, collector, ContextSize::symmetrical(3));
640                        let _ = gix::diff::blob::diff(algorithm, &input, sink);
641                    }
642                }
643            }
644
645            resource_cache.clear_resource_cache_keep_allocation();
646
647            lines_added += added;
648            lines_removed += removed;
649
650            file_changes.push(FileChange {
651                path,
652                change_type: change_type.to_string(),
653                hunks,
654            });
655
656            Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()))
657        })
658        .map_err(|e| Error::GitCorrupt(e.to_string()))?;
659
660    Ok(CommitDetail {
661        repo,
662        sha,
663        author,
664        committer,
665        date,
666        committer_date,
667        message,
668        parents,
669        refs,
670        files_changed,
671        lines_added,
672        lines_removed,
673        file_changes,
674    })
675}