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