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