1use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21use chrono::{DateTime, Utc};
22use gix::bstr::ByteSlice;
23use indicatif::{ProgressBar, ProgressStyle};
24use miette::{IntoDiagnostic, Result};
25
26use crate::cli::{CYAN, GREEN, RED, RESET};
27use serde::Serialize;
28use tera::{Context, Tera};
29use tracing::warn;
30
31use crate::config::{AbbayeConfig, GitUiConfig};
32
33pub const TEMPLATE_GIT_LOG: &str = include_str!("templates/git_log.html.j2");
36pub const TEMPLATE_GIT_COMMIT: &str = include_str!("templates/git_commit.html.j2");
37pub const TEMPLATE_GIT_REFS: &str = include_str!("templates/git_refs.html.j2");
38pub const TEMPLATE_GIT_TREE: &str = include_str!("templates/git_tree.html.j2");
39pub const TEMPLATE_GIT_BLOB: &str = include_str!("templates/git_blob.html.j2");
40
41#[derive(Clone, Serialize)]
44struct CommitParent {
45 hash: String,
46 hash_short: String,
47}
48
49#[derive(Clone, Serialize)]
52struct CommitInfo {
53 hash: String,
54 hash_short: String,
55 author_name: String,
56 author_email: String,
57 date_iso: String,
59 date: String,
61 datetime_display: String,
63 subject: String,
65 body: Option<String>,
67 parents: Vec<CommitParent>,
68 ref_badges: Vec<RefBadge>,
70}
71
72#[derive(Serialize)]
73struct RefInfo {
74 name: String,
75 short_name: String,
76 hash: String,
77 hash_short: String,
78}
79
80#[derive(Serialize)]
83#[serde(rename_all = "lowercase")]
84enum DiffLineKind {
85 Header, Hunk, Added, Removed, Context, }
91
92#[derive(Serialize)]
93struct DiffLine {
94 kind: DiffLineKind,
95 content: String,
96}
97
98#[derive(Serialize)]
99struct ChangedFile {
100 path: String,
101 status: String,
103 diff_lines: Vec<DiffLine>,
104}
105
106#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
109#[serde(rename_all = "lowercase")]
110enum RefBadgeKind {
111 Tag,
113 Branch,
114}
115
116#[derive(Clone, Serialize)]
118struct RefBadge {
119 label: String,
120 kind: RefBadgeKind,
121}
122
123#[derive(Serialize)]
125struct Crumb {
126 name: String,
127 url: Option<String>,
130}
131
132#[derive(Serialize)]
134#[serde(rename_all = "lowercase")]
135enum TreeEntryKind {
136 Tree,
137 Blob,
138}
139
140#[derive(Serialize)]
142struct TreeEntry {
143 name: String,
144 kind: TreeEntryKind,
145 url: String,
147}
148
149#[derive(Serialize)]
151struct BranchNav {
152 short_name: String,
153 filename: String,
155 is_current: bool,
156}
157
158#[derive(Clone)]
161struct BranchEntry {
162 short_name: String,
163 filename: String,
165 tip: gix::ObjectId,
166}
167
168fn make_spinner(label: &str) -> ProgressBar {
173 let pb = ProgressBar::new_spinner();
174 pb.set_style(
175 ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}")
176 .expect("valid template")
177 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
178 );
179 pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
180 pb.enable_steady_tick(Duration::from_millis(100));
181 pb
182}
183
184pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> {
185 let output_dir = &config.site.output_dir;
186 let ui_dir = output_dir.join("repository");
187 let bare_dir = output_dir.join("repository.git");
188
189 tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
190 tokio::fs::create_dir_all(ui_dir.join("commit"))
191 .await
192 .into_diagnostic()?;
193
194 let repo_path: PathBuf = git_cfg
195 .repo_path
196 .clone()
197 .unwrap_or_else(|| PathBuf::from("."));
198
199 let max_commits = git_cfg.max_commits;
200 let default_branch = git_cfg.default_branch.clone();
201 let repo_path_clone = repo_path.clone();
202
203 let clone_url = generate_clone_command(config, git_cfg);
206
207 let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
215 tokio::task::spawn_blocking(move || -> Result<_> {
216 let repo = match gix::discover(&repo_path_clone) {
217 Ok(r) => r,
218 Err(e) => {
219 warn!(
220 "git_ui: could not open repository at {}: {e}",
221 repo_path_clone.display()
222 );
223 return Ok((vec![], vec![], vec![], vec![], vec![]));
224 }
225 };
226
227 let branches = collect_branch_entries(&repo, &default_branch)?;
228 let (tags, ref_branches) = collect_refs(&repo)?;
229 let ref_labels = build_ref_labels(&repo)?;
230
231 let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
233 let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();
234
235 for branch in &branches {
236 let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
237 for c in &commits {
238 unique_map
239 .entry(c.hash.clone())
240 .or_insert_with(|| c.clone());
241 }
242 branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
243 }
244
245 let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();
246
247 let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
249 for branch in &branches {
250 seen.insert(branch.tip.to_string(), branch.tip);
251 }
252 let refs_platform = repo.references().into_diagnostic()?;
253 for reference in refs_platform.all().into_diagnostic()? {
254 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
255 let name = reference.name().as_bstr().to_str_lossy().into_owned();
256 if !name.starts_with("refs/tags/") {
257 continue;
258 }
259 if let Ok(id) = reference.peel_to_id() {
260 let hash = id.to_string();
261 seen.entry(hash).or_insert_with(|| id.detach());
262 }
263 }
264 let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();
265
266 Ok((
267 branch_pages,
268 unique_commits,
269 tags,
270 ref_branches,
271 browse_revisions,
272 ))
273 })
274 .await
275 .into_diagnostic()??;
276
277 if branch_pages.is_empty() {
278 return Ok(());
280 }
281
282 let pb = make_spinner("git ui");
283
284 pb.set_message("cloning bare repository…");
286 if let Err(e) = export_bare_clone(&repo_path, &bare_dir).await {
287 pb.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}"));
288 return Err(e);
289 }
290
291 let mut tera = Tera::default();
293 let theme_path = PathBuf::from(".abbaye").join("theme");
294 for (name, builtin) in [
295 ("git_log.html", TEMPLATE_GIT_LOG),
296 ("git_commit.html", TEMPLATE_GIT_COMMIT),
297 ("git_refs.html", TEMPLATE_GIT_REFS),
298 ] {
299 let override_path = theme_path.join(format!("{name}.j2"));
300 if override_path.is_file() {
301 tera.add_template_file(&override_path, Some(name))
302 .into_diagnostic()?;
303 } else {
304 tera.add_raw_template(name, builtin).into_diagnostic()?;
305 }
306 }
307 crate::site::load_extra_theme_templates(
308 &mut tera,
309 &theme_path,
310 &["git_log.html", "git_commit.html", "git_refs.html"],
311 )?;
312
313 let mut nav_entries: Vec<(String, String)> = branch_pages
317 .iter()
318 .map(|(name, file, _)| (name.clone(), file.clone()))
319 .collect();
320 nav_entries.sort_by(|(na, fa), (nb, fb)| {
321 let a_default = fa == "index.html";
322 let b_default = fb == "index.html";
323 b_default.cmp(&a_default).then(na.cmp(nb))
324 });
325
326 pb.set_message("rendering log pages…");
328 for (short_name, filename, commits) in &branch_pages {
329 let truncated = commits.len() >= max_commits;
330
331 let branch_nav: Vec<BranchNav> = nav_entries
332 .iter()
333 .map(|(bn, bf)| BranchNav {
334 short_name: bn.clone(),
335 filename: bf.clone(),
336 is_current: bn == short_name,
337 })
338 .collect();
339
340 let mut ctx = Context::new();
341 ctx.insert("project_name", &config.site.name);
342 ctx.insert("lang", &config.site.lang);
343 ctx.insert("clone_url", &clone_url);
344 ctx.insert("current_branch", short_name);
345 ctx.insert("branch_nav", &branch_nav);
346 ctx.insert("commits", commits);
347 ctx.insert("truncated", &truncated);
348 ctx.insert("root_path", "../");
349
350 let html = tera.render("git_log.html", &ctx).into_diagnostic()?;
351 tokio::fs::write(ui_dir.join(filename), html)
352 .await
353 .into_diagnostic()?;
354 }
355
356 {
358 let mut ctx = Context::new();
359 ctx.insert("project_name", &config.site.name);
360 ctx.insert("lang", &config.site.lang);
361 ctx.insert("clone_url", &clone_url);
362 ctx.insert("tags", &tags);
363 ctx.insert("branches", &ref_branches);
364 ctx.insert("root_path", "../");
365
366 let html = tera.render("git_refs.html", &ctx).into_diagnostic()?;
367 tokio::fs::write(ui_dir.join("refs.html"), html)
368 .await
369 .into_diagnostic()?;
370 }
371
372 pb.set_message(format!("rendering {} commit pages…", unique_commits.len()));
374 for commit_info in &unique_commits {
375 let changed_files = get_changed_files(&commit_info.hash).await?;
376 let has_browse = !commit_info.ref_badges.is_empty();
377
378 let mut ctx = Context::new();
379 ctx.insert("project_name", &config.site.name);
380 ctx.insert("lang", &config.site.lang);
381 ctx.insert("clone_url", &clone_url);
382 ctx.insert("commit", commit_info);
383 ctx.insert("changed_files", &changed_files);
384 ctx.insert("has_browse", &has_browse);
385 ctx.insert("root_path", "../../");
386
387 let html = tera.render("git_commit.html", &ctx).into_diagnostic()?;
388 tokio::fs::write(
389 ui_dir
390 .join("commit")
391 .join(format!("{}.html", commit_info.hash)),
392 html,
393 )
394 .await
395 .into_diagnostic()?;
396 }
397
398 if !browse_revisions.is_empty() {
400 pb.set_message(format!(
401 "building browse pages for {} revision(s)…",
402 browse_revisions.len()
403 ));
404 let browse_dir = ui_dir.join("browse");
405 tokio::fs::create_dir_all(&browse_dir)
406 .await
407 .into_diagnostic()?;
408
409 let project_name = config.site.name.clone();
410 let lang = config.site.lang.clone();
411 let clone_url_browse = clone_url.clone();
412 let theme_path = PathBuf::from(".abbaye").join("theme");
413 let repo_path_browse = repo_path.clone();
414
415 tokio::task::spawn_blocking(move || {
416 build_browse_pages(
417 &browse_revisions,
418 &browse_dir,
419 &repo_path_browse,
420 &theme_path,
421 &project_name,
422 &lang,
423 &clone_url_browse,
424 )
425 })
426 .await
427 .into_diagnostic()??
428 }
429
430 pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
431 Ok(())
432}
433
434pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
435 let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
436 config.site.base_url.as_ref().map(|base| {
437 format!(
438 "{}/repository.git {}",
439 base.trim_end_matches('/'),
440 if config.site.name.contains(" ") {
441 format!("'{}'", config.site.name)
442 } else {
443 config.site.name.clone()
444 }
445 )
446 })
447 });
448 clone_url
449}
450
451fn collect_branch_entries(
462 repo: &gix::Repository,
463 default_branch: &str,
464) -> Result<Vec<BranchEntry>> {
465 let mut entries: Vec<BranchEntry> = Vec::new();
466
467 let refs_platform = repo.references().into_diagnostic()?;
468 for reference in refs_platform.all().into_diagnostic()? {
469 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
470 let name = reference.name().as_bstr().to_str_lossy().into_owned();
471
472 if !name.starts_with("refs/heads/") {
473 continue;
474 }
475
476 let short_name = name.trim_start_matches("refs/heads/").to_string();
477 let tip = match reference.peel_to_id() {
478 Ok(id) => id.detach(),
479 Err(_) => continue,
480 };
481
482 let filename = format!("{}.html", short_name.replace('/', "-"));
484 entries.push(BranchEntry {
485 short_name,
486 filename,
487 tip,
488 });
489 }
490
491 entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
493
494 if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
496 e.filename = "index.html".to_string();
497 } else if let Some(first) = entries.first_mut() {
498 warn!(
499 "git_ui: default branch '{}' not found; using '{}' as index.html",
500 default_branch, first.short_name
501 );
502 first.filename = "index.html".to_string();
503 }
504
505 Ok(entries)
506}
507
508fn build_ref_labels(repo: &gix::Repository) -> Result<HashMap<String, Vec<RefBadge>>> {
511 let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
512
513 let refs_platform = repo.references().into_diagnostic()?;
514 for reference in refs_platform.all().into_diagnostic()? {
515 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
516 let name = reference.name().as_bstr().to_str_lossy().into_owned();
517
518 if name == "HEAD" || name.starts_with("refs/remotes/") {
519 continue;
520 }
521
522 let hash = match reference.peel_to_id() {
523 Ok(id) => id.to_string(),
524 Err(_) => continue,
525 };
526
527 let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
528 RefBadge {
529 label: label.to_string(),
530 kind: RefBadgeKind::Tag,
531 }
532 } else if let Some(label) = name.strip_prefix("refs/heads/") {
533 RefBadge {
534 label: label.to_string(),
535 kind: RefBadgeKind::Branch,
536 }
537 } else {
538 continue;
539 };
540
541 map.entry(hash).or_default().push(badge);
542 }
543
544 for badges in map.values_mut() {
546 badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
547 }
548
549 Ok(map)
550}
551
552fn collect_commits(
554 repo: &gix::Repository,
555 tip: gix::ObjectId,
556 max: usize,
557 ref_labels: &HashMap<String, Vec<RefBadge>>,
558) -> Result<Vec<CommitInfo>> {
559 let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
560 let mut commits = Vec::new();
561
562 for info in walk.take(max) {
563 let info = info.into_diagnostic()?;
564 let id = info.id;
565
566 let object = repo.find_object(id).into_diagnostic()?;
567 let commit = object.into_commit();
568 let decoded = commit.decode().into_diagnostic()?;
569
570 let author = decoded.author().into_diagnostic()?;
571 let author_name = author.name.to_str_lossy().into_owned();
572 let author_email = author.email.to_str_lossy().into_owned();
573 let unix_secs: i64 = author
574 .time
575 .split_whitespace()
576 .next()
577 .and_then(|s| s.parse().ok())
578 .unwrap_or(0);
579
580 let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
581 let date = dt.format("%Y-%m-%d").to_string();
582 let date_iso = dt.to_rfc3339();
583 let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
584
585 let raw_msg = decoded.message.to_str_lossy();
586 let (subject, body) = parse_message(&raw_msg);
587
588 let hash = id.to_string();
589 let hash_short = hash[..7].to_string();
590
591 let parents = info
592 .parent_ids
593 .iter()
594 .map(|p| {
595 let h = p.to_string();
596 let hs = h[..7].to_string();
597 CommitParent {
598 hash: h,
599 hash_short: hs,
600 }
601 })
602 .collect();
603
604 let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
605
606 commits.push(CommitInfo {
607 hash,
608 hash_short,
609 author_name,
610 author_email,
611 date,
612 date_iso,
613 datetime_display,
614 subject,
615 body,
616 parents,
617 ref_badges,
618 });
619 }
620
621 Ok(commits)
622}
623
624fn collect_refs(repo: &gix::Repository) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
626 let mut tags: Vec<RefInfo> = Vec::new();
627 let mut branches: Vec<RefInfo> = Vec::new();
628
629 let refs_platform = repo.references().into_diagnostic()?;
630 for reference in refs_platform.all().into_diagnostic()? {
631 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
632 let name = reference.name().as_bstr().to_str_lossy().into_owned();
633
634 if name.starts_with("refs/remotes/") || name == "HEAD" {
635 continue;
636 }
637
638 let hash = match reference.peel_to_id() {
639 Ok(id) => id.to_string(),
640 Err(_) => continue,
641 };
642 let hash_short = hash[..7.min(hash.len())].to_string();
643
644 if name.starts_with("refs/tags/") {
645 let short_name = name.trim_start_matches("refs/tags/").to_string();
646 tags.push(RefInfo {
647 name,
648 short_name,
649 hash,
650 hash_short,
651 });
652 } else if name.starts_with("refs/heads/") {
653 let short_name = name.trim_start_matches("refs/heads/").to_string();
654 branches.push(RefInfo {
655 name,
656 short_name,
657 hash,
658 hash_short,
659 });
660 }
661 }
662
663 tags.sort_by(|a, b| b.name.cmp(&a.name));
665 branches.sort_by(|a, b| a.name.cmp(&b.name));
666
667 Ok((tags, branches))
668}
669
670async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
673 let output = tokio::process::Command::new("git")
674 .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
675 .output()
676 .await
677 .into_diagnostic()?;
678
679 if !output.status.success() {
680 return Ok(vec![]);
681 }
682
683 Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
684}
685
686fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
694 let mut files: Vec<ChangedFile> = Vec::new();
695 let mut cur_lines: Vec<DiffLine> = Vec::new();
696 let mut cur_path = String::new();
697 let mut cur_status = "modified";
698
699 let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
700 lines.push(DiffLine {
701 kind,
702 content: content.to_string(),
703 });
704 };
705
706 for line in text.lines() {
707 if line.starts_with("diff --git ") {
708 if !cur_path.is_empty() {
709 files.push(ChangedFile {
710 path: cur_path.clone(),
711 status: cur_status.to_string(),
712 diff_lines: std::mem::take(&mut cur_lines),
713 });
714 }
715 cur_path = line
718 .rsplit_once(" b/")
719 .map(|(_, p)| p.to_string())
720 .unwrap_or_default();
721 cur_status = "modified";
722 push_line(&mut cur_lines, DiffLineKind::Header, line);
723 } else if line.starts_with("new file mode") {
724 cur_status = "added";
725 push_line(&mut cur_lines, DiffLineKind::Header, line);
726 } else if line.starts_with("deleted file mode") {
727 cur_status = "deleted";
728 push_line(&mut cur_lines, DiffLineKind::Header, line);
729 } else if line.starts_with("rename from") || line.starts_with("rename to") {
730 cur_status = "renamed";
731 push_line(&mut cur_lines, DiffLineKind::Header, line);
732 } else if line.starts_with("similarity index")
733 || line.starts_with("copy from")
734 || line.starts_with("copy to")
735 || line.starts_with("index ")
736 || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("Binary files")
739 || line.starts_with('\\')
740 {
741 push_line(&mut cur_lines, DiffLineKind::Header, line);
742 } else if line.starts_with("@@") {
743 push_line(&mut cur_lines, DiffLineKind::Hunk, line);
744 } else if line.starts_with('+') {
745 push_line(&mut cur_lines, DiffLineKind::Added, line);
746 } else if line.starts_with('-') {
747 push_line(&mut cur_lines, DiffLineKind::Removed, line);
748 } else {
749 push_line(&mut cur_lines, DiffLineKind::Context, line);
750 }
751 }
752
753 if !cur_path.is_empty() {
754 files.push(ChangedFile {
755 path: cur_path,
756 status: cur_status.to_string(),
757 diff_lines: cur_lines,
758 });
759 }
760
761 files
762}
763
764async fn export_bare_clone(source: &Path, dest: &Path) -> Result<()> {
767 use tokio::process::Command;
768
769 if dest.exists() {
770 tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
771 }
772
773 let status = Command::new("git")
774 .arg("clone")
775 .arg("--bare")
776 .arg(source)
777 .arg(dest)
778 .stdout(std::process::Stdio::null())
779 .stderr(std::process::Stdio::null())
780 .status()
781 .await
782 .into_diagnostic()?;
783
784 if !status.success() {
785 return Err(miette::miette!(
786 "git clone --bare failed with exit status {status}"
787 ));
788 }
789
790 let status = Command::new("git")
792 .arg("-C")
793 .arg(dest)
794 .arg("update-server-info")
795 .stdout(std::process::Stdio::null())
796 .stderr(std::process::Stdio::null())
797 .status()
798 .await
799 .into_diagnostic()?;
800
801 if !status.success() {
802 warn!("git update-server-info failed; dumb HTTP cloning may not work");
803 }
804
805 Ok(())
806}
807
808fn build_browse_pages(
815 revisions: &[(String, gix::ObjectId)],
816 browse_dir: &Path, repo_path: &Path, theme_path: &Path, project_name: &str,
820 lang: &Option<String>,
821 clone_url: &Option<String>,
822) -> Result<()> {
823 use syntect::highlighting::ThemeSet;
824 use syntect::parsing::SyntaxSet;
825
826 let ss = SyntaxSet::load_defaults_newlines();
827 let ts = ThemeSet::load_defaults();
828 let theme = &ts.themes["InspiredGitHub"];
829
830 let mut tera = Tera::default();
832 for (name, builtin) in [
833 ("git_tree.html", TEMPLATE_GIT_TREE),
834 ("git_blob.html", TEMPLATE_GIT_BLOB),
835 ] {
836 let override_path = theme_path.join(format!("{name}.j2"));
837 if override_path.is_file() {
838 tera.add_template_file(&override_path, Some(name))
839 .map_err(|e| miette::miette!("{e}"))?;
840 } else {
841 tera.add_raw_template(name, builtin)
842 .map_err(|e| miette::miette!("{e}"))?;
843 }
844 }
845 crate::site::load_extra_theme_templates(
846 &mut tera,
847 theme_path,
848 &["git_tree.html", "git_blob.html"],
849 )?;
850
851 for (hash, oid) in revisions {
852 let rev_dir = browse_dir.join(hash);
853 std::fs::create_dir_all(&rev_dir).into_diagnostic()?;
854
855 let repo = gix::open(repo_path).into_diagnostic()?;
857 let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
858 let decoded = commit_obj.decode().into_diagnostic()?;
859 let tree_id = decoded.tree();
860
861 walk_tree_dir(
862 repo_path,
863 &repo,
864 tree_id,
865 "",
866 hash,
867 &rev_dir,
868 &tera,
869 project_name,
870 lang,
871 clone_url,
872 &ss,
873 theme,
874 )?;
875 }
876
877 Ok(())
878}
879
880#[allow(clippy::too_many_arguments)]
884fn walk_tree_dir(
885 repo_path: &Path,
886 repo: &gix::Repository,
887 tree_id: gix::ObjectId,
888 dir_path: &str, commit_hash: &str,
890 rev_dir: &Path, tera: &Tera,
892 project_name: &str,
893 lang: &Option<String>,
894 clone_url: &Option<String>,
895 ss: &syntect::parsing::SyntaxSet,
896 theme: &syntect::highlighting::Theme,
897) -> Result<()> {
898 let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
899 let decoded = tree_obj.decode().into_diagnostic()?;
900
901 let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();
903
904 let page_dir = if dir_path.is_empty() {
906 rev_dir.to_path_buf()
907 } else {
908 dir_path
909 .split('/')
910 .filter(|s| !s.is_empty())
911 .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
912 };
913 std::fs::create_dir_all(&page_dir).into_diagnostic()?;
914
915 let mut entries: Vec<TreeEntry> = Vec::new();
916 let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
918 let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();
919
920 for entry in decoded.entries.iter() {
921 let name = entry.filename.to_str_lossy().into_owned();
922 let oid: gix::ObjectId = entry.oid.to_owned();
923
924 if entry.mode.is_tree() {
925 entries.push(TreeEntry {
926 url: format!("{name}/index.html"),
927 name: name.clone(),
928 kind: TreeEntryKind::Tree,
929 });
930 subdirs.push((name, oid));
931 } else {
932 entries.push(TreeEntry {
934 url: format!("{name}.html"),
935 name: name.clone(),
936 kind: TreeEntryKind::Blob,
937 });
938 if !entry.mode.is_commit() {
939 blobs.push((name, oid));
941 }
942 }
943 }
944
945 entries.sort_by(|a, b| {
947 let a_tree = matches!(a.kind, TreeEntryKind::Tree);
948 let b_tree = matches!(b.kind, TreeEntryKind::Tree);
949 b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
950 });
951
952 let root_path = "../".repeat(3 + depth);
955 let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
957 let breadcrumbs = make_crumbs(dir_path, false, None);
958
959 let mut ctx = Context::new();
960 ctx.insert("project_name", project_name);
961 ctx.insert("lang", lang);
962 ctx.insert("clone_url", clone_url);
963 ctx.insert("commit_hash", commit_hash);
964 ctx.insert("commit_hash_short", &commit_hash[..7]);
965 ctx.insert("commit_url", &commit_url);
966 ctx.insert("dir_path", dir_path);
967 ctx.insert("entries", &entries);
968 ctx.insert("breadcrumbs", &breadcrumbs);
969 ctx.insert("root_path", &root_path);
970
971 let html = tera
972 .render("git_tree.html", &ctx)
973 .map_err(|e| miette::miette!("{e}"))?;
974 std::fs::write(page_dir.join("index.html"), html).into_diagnostic()?;
975
976 for (name, oid) in subdirs {
978 let child_path = if dir_path.is_empty() {
979 name
980 } else {
981 format!("{dir_path}/{name}")
982 };
983 walk_tree_dir(
984 repo_path,
985 repo,
986 oid,
987 &child_path,
988 commit_hash,
989 rev_dir,
990 tera,
991 project_name,
992 lang,
993 clone_url,
994 ss,
995 theme,
996 )?;
997 }
998
999 for (name, oid) in blobs {
1001 let file_path = if dir_path.is_empty() {
1002 name.clone()
1003 } else {
1004 format!("{dir_path}/{name}")
1005 };
1006 render_blob_page(
1007 repo_path,
1008 &name,
1009 &file_path,
1010 oid,
1011 commit_hash,
1012 depth,
1013 &page_dir,
1014 tera,
1015 project_name,
1016 lang,
1017 clone_url,
1018 ss,
1019 theme,
1020 )?;
1021 }
1022
1023 Ok(())
1024}
1025
1026#[allow(clippy::too_many_arguments)]
1028fn render_blob_page(
1029 repo_path: &Path,
1030 filename: &str,
1031 file_path: &str, oid: gix::ObjectId,
1033 commit_hash: &str,
1034 depth: usize, page_dir: &Path, tera: &Tera,
1037 project_name: &str,
1038 lang: &Option<String>,
1039 clone_url: &Option<String>,
1040 ss: &syntect::parsing::SyntaxSet,
1041 theme: &syntect::highlighting::Theme,
1042) -> Result<()> {
1043 const MAX_BLOB_BYTES: usize = 1024 * 1024; let data: Vec<u8> = std::process::Command::new("git")
1047 .current_dir(repo_path)
1048 .args(["cat-file", "blob", &oid.to_string()])
1049 .output()
1050 .map(|o| o.stdout)
1051 .unwrap_or_default();
1052
1053 let is_binary = data[..data.len().min(8192)].contains(&0u8);
1054 let too_large = data.len() > MAX_BLOB_BYTES;
1055
1056 let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
1057 None
1058 } else {
1059 let text = String::from_utf8_lossy(&data);
1060 let ext = std::path::Path::new(filename)
1061 .extension()
1062 .and_then(|s| s.to_str())
1063 .unwrap_or("");
1064 let syntax = ss
1065 .find_syntax_by_extension(ext)
1066 .or_else(|| {
1067 text.lines()
1068 .next()
1069 .and_then(|l| ss.find_syntax_by_first_line(l))
1070 })
1071 .unwrap_or_else(|| ss.find_syntax_plain_text());
1072 Some(
1073 syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
1074 .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
1075 )
1076 };
1077
1078 let root_path = "../".repeat(3 + depth);
1079 let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
1080 let breadcrumbs = make_crumbs(
1081 std::path::Path::new(file_path)
1082 .parent()
1083 .and_then(|p| p.to_str())
1084 .unwrap_or(""),
1085 true,
1086 Some(filename),
1087 );
1088
1089 let mut ctx = Context::new();
1090 ctx.insert("project_name", project_name);
1091 ctx.insert("lang", lang);
1092 ctx.insert("clone_url", clone_url);
1093 ctx.insert("commit_hash", commit_hash);
1094 ctx.insert("commit_hash_short", &commit_hash[..7]);
1095 ctx.insert("commit_url", &commit_url);
1096 ctx.insert("file_path", file_path);
1097 ctx.insert("filename", filename);
1098 ctx.insert("breadcrumbs", &breadcrumbs);
1099 ctx.insert("content_html", &content_html);
1100 ctx.insert("is_binary", &is_binary);
1101 ctx.insert("too_large", &too_large);
1102 ctx.insert("size", &data.len());
1103 ctx.insert("root_path", &root_path);
1104
1105 let html = tera
1106 .render("git_blob.html", &ctx)
1107 .map_err(|e| miette::miette!("{e}"))?;
1108 std::fs::write(page_dir.join(format!("{filename}.html")), html).into_diagnostic()?;
1109
1110 Ok(())
1111}
1112
1113fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
1118 let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
1119 let depth = parts.len();
1120 let mut crumbs = Vec::new();
1121
1122 let root_url = if depth == 0 && !is_blob {
1124 None } else {
1126 Some(format!("{}index.html", "../".repeat(depth)))
1127 };
1128 crumbs.push(Crumb {
1129 name: "~".to_string(),
1130 url: root_url,
1131 });
1132
1133 for (i, &part) in parts.iter().enumerate() {
1135 let is_last_and_tree = i == depth - 1 && !is_blob;
1136 let url = if is_last_and_tree {
1137 None } else {
1139 let levels_up = depth - i - 1;
1141 Some(format!("{}index.html", "../".repeat(levels_up)))
1142 };
1143 crumbs.push(Crumb {
1144 name: part.to_string(),
1145 url,
1146 });
1147 }
1148
1149 if is_blob {
1151 if let Some(name) = filename {
1152 crumbs.push(Crumb {
1153 name: name.to_string(),
1154 url: None,
1155 });
1156 }
1157 }
1158
1159 crumbs
1160}
1161
1162fn escape_html(s: &str) -> String {
1163 s.replace('&', "&")
1164 .replace('<', "<")
1165 .replace('>', ">")
1166}
1167
1168fn parse_message(raw: &str) -> (String, Option<String>) {
1172 if let Some(idx) = raw.find("\n\n") {
1173 let subject = raw[..idx].trim().to_string();
1174 let body = raw[idx + 2..].trim().to_string();
1175 let body = if body.is_empty() { None } else { Some(body) };
1176 (subject, body)
1177 } else {
1178 (raw.trim().to_string(), None)
1179 }
1180}