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