1use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21use chrono::{DateTime, Utc};
22use gix::bstr::ByteSlice;
23use globset::{Glob, GlobSet, GlobSetBuilder};
24use indicatif::{ProgressBar, ProgressStyle};
25use miette::{IntoDiagnostic, Result};
26
27use crate::cli::{CYAN, GREEN, RED, RESET};
28use serde::Serialize;
29use tera::{Context, Tera};
30use tracing::warn;
31
32use crate::config::{AbbayeConfig, GitUiConfig};
33
34pub const TEMPLATE_GIT_LOG: &str = include_str!("templates/git_log.html.j2");
37pub const TEMPLATE_GIT_COMMIT: &str = include_str!("templates/git_commit.html.j2");
38pub const TEMPLATE_GIT_REFS: &str = include_str!("templates/git_refs.html.j2");
39pub const TEMPLATE_GIT_TREE: &str = include_str!("templates/git_tree.html.j2");
40pub const TEMPLATE_GIT_BLOB: &str = include_str!("templates/git_blob.html.j2");
41
42#[derive(Clone, Serialize)]
45struct CommitParent {
46 hash: String,
47 hash_short: String,
48}
49
50#[derive(Clone, Serialize)]
53struct CommitInfo {
54 hash: String,
55 hash_short: String,
56 author_name: String,
57 author_email: String,
58 date_iso: String,
60 date: String,
62 datetime_display: String,
64 subject: String,
66 body: Option<String>,
68 parents: Vec<CommitParent>,
69 ref_badges: Vec<RefBadge>,
71}
72
73#[derive(Serialize)]
74struct RefInfo {
75 name: String,
76 short_name: String,
77 hash: String,
78 hash_short: String,
79}
80
81#[derive(Serialize)]
84#[serde(rename_all = "lowercase")]
85enum DiffLineKind {
86 Header, Hunk, Added, Removed, Context, }
92
93#[derive(Serialize)]
94struct DiffLine {
95 kind: DiffLineKind,
96 content: String,
97}
98
99#[derive(Serialize)]
100struct ChangedFile {
101 path: String,
102 status: String,
104 diff_lines: Vec<DiffLine>,
105}
106
107#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
110#[serde(rename_all = "lowercase")]
111enum RefBadgeKind {
112 Tag,
114 Branch,
115}
116
117#[derive(Clone, Serialize)]
119struct RefBadge {
120 label: String,
121 kind: RefBadgeKind,
122}
123
124#[derive(Serialize)]
126struct Crumb {
127 name: String,
128 url: Option<String>,
131}
132
133#[derive(Serialize)]
135#[serde(rename_all = "lowercase")]
136enum TreeEntryKind {
137 Tree,
138 Blob,
139}
140
141#[derive(Serialize)]
143struct TreeEntry {
144 name: String,
145 kind: TreeEntryKind,
146 url: String,
148}
149
150#[derive(Serialize)]
152struct BranchNav {
153 short_name: String,
154 filename: String,
156 is_current: bool,
157}
158
159#[derive(Clone)]
162struct BranchEntry {
163 short_name: String,
164 filename: String,
166 tip: gix::ObjectId,
167}
168
169fn make_spinner(label: &str) -> ProgressBar {
174 let pb = ProgressBar::new_spinner();
175 pb.set_style(
176 ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}")
177 .expect("valid template")
178 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
179 );
180 pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
181 pb.enable_steady_tick(Duration::from_millis(100));
182 pb
183}
184
185pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> {
186 let output_dir = &config.site.output_dir;
187 let ui_dir = output_dir.join("repository");
188 let bare_dir = output_dir.join("repository.git");
189
190 tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
191 tokio::fs::create_dir_all(ui_dir.join("commit"))
192 .await
193 .into_diagnostic()?;
194
195 let repo_path: PathBuf = git_cfg
196 .repo_path
197 .clone()
198 .unwrap_or_else(|| PathBuf::from("."));
199
200 let max_commits = git_cfg.max_commits;
201 let default_branch = git_cfg.default_branch.clone();
202 let include = build_globset(&git_cfg.include)?;
203 let exclude = build_globset(&git_cfg.exclude)?;
204 let repo_path_clone = repo_path.clone();
205
206 let bare_default_branch = default_branch.clone();
210 let bare_include = include.clone();
211 let bare_exclude = exclude.clone();
212
213 let clone_url = generate_clone_command(config, git_cfg);
216
217 let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
225 tokio::task::spawn_blocking(move || -> Result<_> {
226 let repo = match gix::discover(&repo_path_clone) {
227 Ok(r) => r,
228 Err(e) => {
229 warn!(
230 "git_ui: could not open repository at {}: {e}",
231 repo_path_clone.display()
232 );
233 return Ok((vec![], vec![], vec![], vec![], vec![]));
234 }
235 };
236
237 let branches = collect_branch_entries(&repo, &default_branch, &include, &exclude)?;
238 let (tags, ref_branches) = collect_refs(&repo, &include, &exclude)?;
239 let ref_labels = build_ref_labels(&repo, &include, &exclude)?;
240
241 let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
243 let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();
244
245 for branch in &branches {
246 let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
247 for c in &commits {
248 unique_map
249 .entry(c.hash.clone())
250 .or_insert_with(|| c.clone());
251 }
252 branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
253 }
254
255 let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();
256
257 let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
259 for branch in &branches {
260 seen.insert(branch.tip.to_string(), branch.tip);
261 }
262 let refs_platform = repo.references().into_diagnostic()?;
263 for reference in refs_platform.all().into_diagnostic()? {
264 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
265 let name = reference.name().as_bstr().to_str_lossy().into_owned();
266 if !name.starts_with("refs/tags/") {
267 continue;
268 }
269 let short_name = name.trim_start_matches("refs/tags/");
270 if !ref_is_included(short_name, &include, &exclude) {
271 continue;
272 }
273 if let Ok(id) = reference.peel_to_id() {
274 let hash = id.to_string();
275 seen.entry(hash).or_insert_with(|| id.detach());
276 }
277 }
278 let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();
279
280 Ok((
281 branch_pages,
282 unique_commits,
283 tags,
284 ref_branches,
285 browse_revisions,
286 ))
287 })
288 .await
289 .into_diagnostic()??;
290
291 if branch_pages.is_empty() {
292 return Ok(());
294 }
295
296 let pb = make_spinner("git ui");
297
298 pb.set_message("cloning bare repository…");
300 if let Err(e) = export_bare_clone(
301 &repo_path,
302 &bare_dir,
303 &bare_default_branch,
304 &bare_include,
305 &bare_exclude,
306 )
307 .await
308 {
309 pb.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}"));
310 return Err(e);
311 }
312
313 let mut tera = Tera::default();
315 let theme_path = PathBuf::from(".abbaye").join("theme");
316 for (name, builtin) in [
317 ("git_log.html", TEMPLATE_GIT_LOG),
318 ("git_commit.html", TEMPLATE_GIT_COMMIT),
319 ("git_refs.html", TEMPLATE_GIT_REFS),
320 ] {
321 let override_path = theme_path.join(format!("{name}.j2"));
322 if override_path.is_file() {
323 tera.add_template_file(&override_path, Some(name))
324 .into_diagnostic()?;
325 } else {
326 tera.add_raw_template(name, builtin).into_diagnostic()?;
327 }
328 }
329 crate::site::load_extra_theme_templates(
330 &mut tera,
331 &theme_path,
332 &["git_log.html", "git_commit.html", "git_refs.html"],
333 )?;
334
335 let mut nav_entries: Vec<(String, String)> = branch_pages
339 .iter()
340 .map(|(name, file, _)| (name.clone(), file.clone()))
341 .collect();
342 nav_entries.sort_by(|(na, fa), (nb, fb)| {
343 let a_default = fa == "index.html";
344 let b_default = fb == "index.html";
345 b_default.cmp(&a_default).then(na.cmp(nb))
346 });
347
348 pb.set_message("rendering log pages…");
350 for (short_name, filename, commits) in &branch_pages {
351 let truncated = commits.len() >= max_commits;
352
353 let branch_nav: Vec<BranchNav> = nav_entries
354 .iter()
355 .map(|(bn, bf)| BranchNav {
356 short_name: bn.clone(),
357 filename: bf.clone(),
358 is_current: bn == short_name,
359 })
360 .collect();
361
362 let mut ctx = Context::new();
363 ctx.insert("project_name", &config.site.name);
364 ctx.insert("lang", &config.site.lang);
365 ctx.insert("clone_url", &clone_url);
366 ctx.insert("current_branch", short_name);
367 ctx.insert("branch_nav", &branch_nav);
368 ctx.insert("commits", commits);
369 ctx.insert("truncated", &truncated);
370 ctx.insert("root_path", "../");
371
372 let html = tera.render("git_log.html", &ctx).into_diagnostic()?;
373 tokio::fs::write(ui_dir.join(filename), html)
374 .await
375 .into_diagnostic()?;
376 }
377
378 {
380 let mut ctx = Context::new();
381 ctx.insert("project_name", &config.site.name);
382 ctx.insert("lang", &config.site.lang);
383 ctx.insert("clone_url", &clone_url);
384 ctx.insert("tags", &tags);
385 ctx.insert("branches", &ref_branches);
386 ctx.insert("root_path", "../");
387
388 let html = tera.render("git_refs.html", &ctx).into_diagnostic()?;
389 tokio::fs::write(ui_dir.join("refs.html"), html)
390 .await
391 .into_diagnostic()?;
392 }
393
394 pb.set_message(format!("rendering {} commit pages…", unique_commits.len()));
396 for commit_info in &unique_commits {
397 let changed_files = get_changed_files(&commit_info.hash).await?;
398 let has_browse = !commit_info.ref_badges.is_empty();
399
400 let mut ctx = Context::new();
401 ctx.insert("project_name", &config.site.name);
402 ctx.insert("lang", &config.site.lang);
403 ctx.insert("clone_url", &clone_url);
404 ctx.insert("commit", commit_info);
405 ctx.insert("changed_files", &changed_files);
406 ctx.insert("has_browse", &has_browse);
407 ctx.insert("root_path", "../../");
408
409 let html = tera.render("git_commit.html", &ctx).into_diagnostic()?;
410 tokio::fs::write(
411 ui_dir
412 .join("commit")
413 .join(format!("{}.html", commit_info.hash)),
414 html,
415 )
416 .await
417 .into_diagnostic()?;
418 }
419
420 if !browse_revisions.is_empty() {
422 pb.set_message(format!(
423 "building browse pages for {} revision(s)…",
424 browse_revisions.len()
425 ));
426 let browse_dir = ui_dir.join("browse");
427 tokio::fs::create_dir_all(&browse_dir)
428 .await
429 .into_diagnostic()?;
430
431 let project_name = config.site.name.clone();
432 let lang = config.site.lang.clone();
433 let clone_url_browse = clone_url.clone();
434 let theme_path = PathBuf::from(".abbaye").join("theme");
435 let repo_path_browse = repo_path.clone();
436
437 tokio::task::spawn_blocking(move || {
438 build_browse_pages(
439 &browse_revisions,
440 &browse_dir,
441 &repo_path_browse,
442 &theme_path,
443 &project_name,
444 &lang,
445 &clone_url_browse,
446 )
447 })
448 .await
449 .into_diagnostic()??
450 }
451
452 pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
453 Ok(())
454}
455
456pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
457 let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
458 config.site.base_url.as_ref().map(|base| {
459 format!(
460 "{}/repository.git {}",
461 base.trim_end_matches('/'),
462 if config.site.name.contains(" ") {
463 format!("'{}'", config.site.name)
464 } else {
465 config.site.name.clone()
466 }
467 )
468 })
469 });
470 clone_url
471}
472
473fn build_globset(patterns: &[String]) -> Result<GlobSet> {
479 let mut builder = GlobSetBuilder::new();
480 for pattern in patterns {
481 let glob = Glob::new(pattern)
482 .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
483 builder.add(glob);
484 }
485 builder.build().into_diagnostic()
486}
487
488fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
496 let included = include.is_empty() || include.is_match(short_name);
497 included && !exclude.is_match(short_name)
498}
499
500fn collect_branch_entries(
512 repo: &gix::Repository,
513 default_branch: &str,
514 include: &GlobSet,
515 exclude: &GlobSet,
516) -> Result<Vec<BranchEntry>> {
517 let mut entries: Vec<BranchEntry> = Vec::new();
518
519 let refs_platform = repo.references().into_diagnostic()?;
520 for reference in refs_platform.all().into_diagnostic()? {
521 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
522 let name = reference.name().as_bstr().to_str_lossy().into_owned();
523
524 if !name.starts_with("refs/heads/") {
525 continue;
526 }
527
528 let short_name = name.trim_start_matches("refs/heads/").to_string();
529 if !ref_is_included(&short_name, include, exclude) {
530 continue;
531 }
532 let tip = match reference.peel_to_id() {
533 Ok(id) => id.detach(),
534 Err(_) => continue,
535 };
536
537 let filename = format!("{}.html", short_name.replace('/', "-"));
539 entries.push(BranchEntry {
540 short_name,
541 filename,
542 tip,
543 });
544 }
545
546 entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
548
549 if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
551 e.filename = "index.html".to_string();
552 } else if let Some(first) = entries.first_mut() {
553 warn!(
554 "git_ui: default branch '{}' not found; using '{}' as index.html",
555 default_branch, first.short_name
556 );
557 first.filename = "index.html".to_string();
558 }
559
560 Ok(entries)
561}
562
563fn build_ref_labels(
569 repo: &gix::Repository,
570 include: &GlobSet,
571 exclude: &GlobSet,
572) -> Result<HashMap<String, Vec<RefBadge>>> {
573 let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
574
575 let refs_platform = repo.references().into_diagnostic()?;
576 for reference in refs_platform.all().into_diagnostic()? {
577 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
578 let name = reference.name().as_bstr().to_str_lossy().into_owned();
579
580 if name == "HEAD" || name.starts_with("refs/remotes/") {
581 continue;
582 }
583
584 let hash = match reference.peel_to_id() {
585 Ok(id) => id.to_string(),
586 Err(_) => continue,
587 };
588
589 let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
590 if !ref_is_included(label, include, exclude) {
591 continue;
592 }
593 RefBadge {
594 label: label.to_string(),
595 kind: RefBadgeKind::Tag,
596 }
597 } else if let Some(label) = name.strip_prefix("refs/heads/") {
598 if !ref_is_included(label, include, exclude) {
599 continue;
600 }
601 RefBadge {
602 label: label.to_string(),
603 kind: RefBadgeKind::Branch,
604 }
605 } else {
606 continue;
607 };
608
609 map.entry(hash).or_default().push(badge);
610 }
611
612 for badges in map.values_mut() {
614 badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
615 }
616
617 Ok(map)
618}
619
620fn collect_commits(
622 repo: &gix::Repository,
623 tip: gix::ObjectId,
624 max: usize,
625 ref_labels: &HashMap<String, Vec<RefBadge>>,
626) -> Result<Vec<CommitInfo>> {
627 let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
628 let mut commits = Vec::new();
629
630 for info in walk.take(max) {
631 let info = info.into_diagnostic()?;
632 let id = info.id;
633
634 let object = repo.find_object(id).into_diagnostic()?;
635 let commit = object.into_commit();
636 let decoded = commit.decode().into_diagnostic()?;
637
638 let author = decoded.author().into_diagnostic()?;
639 let author_name = author.name.to_str_lossy().into_owned();
640 let author_email = author.email.to_str_lossy().into_owned();
641 let unix_secs: i64 = author
642 .time
643 .split_whitespace()
644 .next()
645 .and_then(|s| s.parse().ok())
646 .unwrap_or(0);
647
648 let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
649 let date = dt.format("%Y-%m-%d").to_string();
650 let date_iso = dt.to_rfc3339();
651 let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
652
653 let raw_msg = decoded.message.to_str_lossy();
654 let (subject, body) = parse_message(&raw_msg);
655
656 let hash = id.to_string();
657 let hash_short = hash[..7].to_string();
658
659 let parents = info
660 .parent_ids
661 .iter()
662 .map(|p| {
663 let h = p.to_string();
664 let hs = h[..7].to_string();
665 CommitParent {
666 hash: h,
667 hash_short: hs,
668 }
669 })
670 .collect();
671
672 let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
673
674 commits.push(CommitInfo {
675 hash,
676 hash_short,
677 author_name,
678 author_email,
679 date,
680 date_iso,
681 datetime_display,
682 subject,
683 body,
684 parents,
685 ref_badges,
686 });
687 }
688
689 Ok(commits)
690}
691
692fn collect_refs(
697 repo: &gix::Repository,
698 include: &GlobSet,
699 exclude: &GlobSet,
700) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
701 let mut tags: Vec<RefInfo> = Vec::new();
702 let mut branches: Vec<RefInfo> = Vec::new();
703
704 let refs_platform = repo.references().into_diagnostic()?;
705 for reference in refs_platform.all().into_diagnostic()? {
706 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
707 let name = reference.name().as_bstr().to_str_lossy().into_owned();
708
709 if name.starts_with("refs/remotes/") || name == "HEAD" {
710 continue;
711 }
712
713 let hash = match reference.peel_to_id() {
714 Ok(id) => id.to_string(),
715 Err(_) => continue,
716 };
717 let hash_short = hash[..7.min(hash.len())].to_string();
718
719 if name.starts_with("refs/tags/") {
720 let short_name = name.trim_start_matches("refs/tags/").to_string();
721 if !ref_is_included(&short_name, include, exclude) {
722 continue;
723 }
724 tags.push(RefInfo {
725 name,
726 short_name,
727 hash,
728 hash_short,
729 });
730 } else if name.starts_with("refs/heads/") {
731 let short_name = name.trim_start_matches("refs/heads/").to_string();
732 if !ref_is_included(&short_name, include, exclude) {
733 continue;
734 }
735 branches.push(RefInfo {
736 name,
737 short_name,
738 hash,
739 hash_short,
740 });
741 }
742 }
743
744 tags.sort_by(|a, b| b.name.cmp(&a.name));
746 branches.sort_by(|a, b| a.name.cmp(&b.name));
747
748 Ok((tags, branches))
749}
750
751async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
754 let output = tokio::process::Command::new("git")
755 .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
756 .output()
757 .await
758 .into_diagnostic()?;
759
760 if !output.status.success() {
761 return Ok(vec![]);
762 }
763
764 Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
765}
766
767fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
775 let mut files: Vec<ChangedFile> = Vec::new();
776 let mut cur_lines: Vec<DiffLine> = Vec::new();
777 let mut cur_path = String::new();
778 let mut cur_status = "modified";
779
780 let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
781 lines.push(DiffLine {
782 kind,
783 content: content.to_string(),
784 });
785 };
786
787 for line in text.lines() {
788 if line.starts_with("diff --git ") {
789 if !cur_path.is_empty() {
790 files.push(ChangedFile {
791 path: cur_path.clone(),
792 status: cur_status.to_string(),
793 diff_lines: std::mem::take(&mut cur_lines),
794 });
795 }
796 cur_path = line
799 .rsplit_once(" b/")
800 .map(|(_, p)| p.to_string())
801 .unwrap_or_default();
802 cur_status = "modified";
803 push_line(&mut cur_lines, DiffLineKind::Header, line);
804 } else if line.starts_with("new file mode") {
805 cur_status = "added";
806 push_line(&mut cur_lines, DiffLineKind::Header, line);
807 } else if line.starts_with("deleted file mode") {
808 cur_status = "deleted";
809 push_line(&mut cur_lines, DiffLineKind::Header, line);
810 } else if line.starts_with("rename from") || line.starts_with("rename to") {
811 cur_status = "renamed";
812 push_line(&mut cur_lines, DiffLineKind::Header, line);
813 } else if line.starts_with("similarity index")
814 || line.starts_with("copy from")
815 || line.starts_with("copy to")
816 || line.starts_with("index ")
817 || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("Binary files")
820 || line.starts_with('\\')
821 {
822 push_line(&mut cur_lines, DiffLineKind::Header, line);
823 } else if line.starts_with("@@") {
824 push_line(&mut cur_lines, DiffLineKind::Hunk, line);
825 } else if line.starts_with('+') {
826 push_line(&mut cur_lines, DiffLineKind::Added, line);
827 } else if line.starts_with('-') {
828 push_line(&mut cur_lines, DiffLineKind::Removed, line);
829 } else {
830 push_line(&mut cur_lines, DiffLineKind::Context, line);
831 }
832 }
833
834 if !cur_path.is_empty() {
835 files.push(ChangedFile {
836 path: cur_path,
837 status: cur_status.to_string(),
838 diff_lines: cur_lines,
839 });
840 }
841
842 files
843}
844
845async fn export_bare_clone(
854 source: &Path,
855 dest: &Path,
856 default_branch: &str,
857 include: &GlobSet,
858 exclude: &GlobSet,
859) -> Result<()> {
860 use tokio::process::Command;
861
862 if dest.exists() {
863 tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
864 }
865
866 let status = Command::new("git")
867 .arg("clone")
868 .arg("--bare")
869 .arg(source)
870 .arg(dest)
871 .stdout(std::process::Stdio::null())
872 .stderr(std::process::Stdio::null())
873 .status()
874 .await
875 .into_diagnostic()?;
876
877 if !status.success() {
878 return Err(miette::miette!(
879 "git clone --bare failed with exit status {status}"
880 ));
881 }
882
883 prune_excluded_refs(dest, default_branch, include, exclude).await?;
884
885 let status = Command::new("git")
887 .arg("-C")
888 .arg(dest)
889 .arg("update-server-info")
890 .stdout(std::process::Stdio::null())
891 .stderr(std::process::Stdio::null())
892 .status()
893 .await
894 .into_diagnostic()?;
895
896 if !status.success() {
897 warn!("git update-server-info failed; dumb HTTP cloning may not work");
898 }
899
900 Ok(())
901}
902
903async fn prune_excluded_refs(
911 dest: &Path,
912 default_branch: &str,
913 include: &GlobSet,
914 exclude: &GlobSet,
915) -> Result<()> {
916 use tokio::process::Command;
917
918 if include.is_empty() && exclude.is_empty() {
919 return Ok(());
920 }
921
922 let output = Command::new("git")
923 .arg("-C")
924 .arg(dest)
925 .arg("for-each-ref")
926 .arg("--format=%(refname)")
927 .arg("refs/heads")
928 .arg("refs/tags")
929 .output()
930 .await
931 .into_diagnostic()?;
932
933 if !output.status.success() {
934 warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
935 return Ok(());
936 }
937
938 let refnames = String::from_utf8_lossy(&output.stdout);
939 let mut removed_any = false;
940 let mut kept_branches: Vec<String> = Vec::new();
941
942 for refname in refnames.lines() {
943 let short_name = match refname
944 .strip_prefix("refs/heads/")
945 .or_else(|| refname.strip_prefix("refs/tags/"))
946 {
947 Some(s) => s,
948 None => continue,
949 };
950
951 if ref_is_included(short_name, include, exclude) {
952 if refname.starts_with("refs/heads/") {
953 kept_branches.push(short_name.to_string());
954 }
955 continue;
956 }
957
958 let status = Command::new("git")
959 .arg("-C")
960 .arg(dest)
961 .arg("update-ref")
962 .arg("-d")
963 .arg(refname)
964 .stdout(std::process::Stdio::null())
965 .stderr(std::process::Stdio::null())
966 .status()
967 .await
968 .into_diagnostic()?;
969
970 if status.success() {
971 removed_any = true;
972 } else {
973 warn!("failed to delete excluded ref '{refname}' from repository.git");
974 }
975 }
976
977 if !removed_any {
978 return Ok(());
979 }
980
981 let new_head = if kept_branches.iter().any(|b| b == default_branch) {
986 Some(default_branch.to_string())
987 } else if let Some(first) = kept_branches.first() {
988 warn!(
989 "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
990 default_branch, first
991 );
992 Some(first.clone())
993 } else {
994 warn!(
995 "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
996 );
997 None
998 };
999
1000 if let Some(branch) = new_head {
1001 let status = Command::new("git")
1002 .arg("-C")
1003 .arg(dest)
1004 .arg("symbolic-ref")
1005 .arg("HEAD")
1006 .arg(format!("refs/heads/{branch}"))
1007 .stdout(std::process::Stdio::null())
1008 .stderr(std::process::Stdio::null())
1009 .status()
1010 .await
1011 .into_diagnostic()?;
1012
1013 if !status.success() {
1014 warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
1015 }
1016 }
1017
1018 let status = Command::new("git")
1021 .arg("-C")
1022 .arg(dest)
1023 .arg("gc")
1024 .arg("--prune=now")
1025 .arg("--quiet")
1026 .status()
1027 .await
1028 .into_diagnostic()?;
1029
1030 if !status.success() {
1031 warn!(
1032 "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
1033 );
1034 }
1035
1036 Ok(())
1037}
1038
1039fn build_browse_pages(
1046 revisions: &[(String, gix::ObjectId)],
1047 browse_dir: &Path, repo_path: &Path, theme_path: &Path, project_name: &str,
1051 lang: &Option<String>,
1052 clone_url: &Option<String>,
1053) -> Result<()> {
1054 use syntect::highlighting::ThemeSet;
1055 use syntect::parsing::SyntaxSet;
1056
1057 let ss = SyntaxSet::load_defaults_newlines();
1058 let ts = ThemeSet::load_defaults();
1059 let theme = &ts.themes["InspiredGitHub"];
1060
1061 let mut tera = Tera::default();
1063 for (name, builtin) in [
1064 ("git_tree.html", TEMPLATE_GIT_TREE),
1065 ("git_blob.html", TEMPLATE_GIT_BLOB),
1066 ] {
1067 let override_path = theme_path.join(format!("{name}.j2"));
1068 if override_path.is_file() {
1069 tera.add_template_file(&override_path, Some(name))
1070 .map_err(|e| miette::miette!("{e}"))?;
1071 } else {
1072 tera.add_raw_template(name, builtin)
1073 .map_err(|e| miette::miette!("{e}"))?;
1074 }
1075 }
1076 crate::site::load_extra_theme_templates(
1077 &mut tera,
1078 theme_path,
1079 &["git_tree.html", "git_blob.html"],
1080 )?;
1081
1082 for (hash, oid) in revisions {
1083 let rev_dir = browse_dir.join(hash);
1084 std::fs::create_dir_all(&rev_dir).into_diagnostic()?;
1085
1086 let repo = gix::open(repo_path).into_diagnostic()?;
1088 let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
1089 let decoded = commit_obj.decode().into_diagnostic()?;
1090 let tree_id = decoded.tree();
1091
1092 walk_tree_dir(
1093 repo_path,
1094 &repo,
1095 tree_id,
1096 "",
1097 hash,
1098 &rev_dir,
1099 &tera,
1100 project_name,
1101 lang,
1102 clone_url,
1103 &ss,
1104 theme,
1105 )?;
1106 }
1107
1108 Ok(())
1109}
1110
1111#[allow(clippy::too_many_arguments)]
1115fn walk_tree_dir(
1116 repo_path: &Path,
1117 repo: &gix::Repository,
1118 tree_id: gix::ObjectId,
1119 dir_path: &str, commit_hash: &str,
1121 rev_dir: &Path, tera: &Tera,
1123 project_name: &str,
1124 lang: &Option<String>,
1125 clone_url: &Option<String>,
1126 ss: &syntect::parsing::SyntaxSet,
1127 theme: &syntect::highlighting::Theme,
1128) -> Result<()> {
1129 let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
1130 let decoded = tree_obj.decode().into_diagnostic()?;
1131
1132 let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();
1134
1135 let page_dir = if dir_path.is_empty() {
1137 rev_dir.to_path_buf()
1138 } else {
1139 dir_path
1140 .split('/')
1141 .filter(|s| !s.is_empty())
1142 .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
1143 };
1144 std::fs::create_dir_all(&page_dir).into_diagnostic()?;
1145
1146 let mut entries: Vec<TreeEntry> = Vec::new();
1147 let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
1149 let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();
1150
1151 for entry in decoded.entries.iter() {
1152 let name = entry.filename.to_str_lossy().into_owned();
1153 let oid: gix::ObjectId = entry.oid.to_owned();
1154
1155 if entry.mode.is_tree() {
1156 entries.push(TreeEntry {
1157 url: format!("{name}/index.html"),
1158 name: name.clone(),
1159 kind: TreeEntryKind::Tree,
1160 });
1161 subdirs.push((name, oid));
1162 } else {
1163 entries.push(TreeEntry {
1165 url: format!("{name}.html"),
1166 name: name.clone(),
1167 kind: TreeEntryKind::Blob,
1168 });
1169 if !entry.mode.is_commit() {
1170 blobs.push((name, oid));
1172 }
1173 }
1174 }
1175
1176 entries.sort_by(|a, b| {
1178 let a_tree = matches!(a.kind, TreeEntryKind::Tree);
1179 let b_tree = matches!(b.kind, TreeEntryKind::Tree);
1180 b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
1181 });
1182
1183 let root_path = "../".repeat(3 + depth);
1186 let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
1188 let breadcrumbs = make_crumbs(dir_path, false, None);
1189
1190 let mut ctx = Context::new();
1191 ctx.insert("project_name", project_name);
1192 ctx.insert("lang", lang);
1193 ctx.insert("clone_url", clone_url);
1194 ctx.insert("commit_hash", commit_hash);
1195 ctx.insert("commit_hash_short", &commit_hash[..7]);
1196 ctx.insert("commit_url", &commit_url);
1197 ctx.insert("dir_path", dir_path);
1198 ctx.insert("entries", &entries);
1199 ctx.insert("breadcrumbs", &breadcrumbs);
1200 ctx.insert("root_path", &root_path);
1201
1202 let html = tera
1203 .render("git_tree.html", &ctx)
1204 .map_err(|e| miette::miette!("{e}"))?;
1205 std::fs::write(page_dir.join("index.html"), html).into_diagnostic()?;
1206
1207 for (name, oid) in subdirs {
1209 let child_path = if dir_path.is_empty() {
1210 name
1211 } else {
1212 format!("{dir_path}/{name}")
1213 };
1214 walk_tree_dir(
1215 repo_path,
1216 repo,
1217 oid,
1218 &child_path,
1219 commit_hash,
1220 rev_dir,
1221 tera,
1222 project_name,
1223 lang,
1224 clone_url,
1225 ss,
1226 theme,
1227 )?;
1228 }
1229
1230 for (name, oid) in blobs {
1232 let file_path = if dir_path.is_empty() {
1233 name.clone()
1234 } else {
1235 format!("{dir_path}/{name}")
1236 };
1237 render_blob_page(
1238 repo_path,
1239 &name,
1240 &file_path,
1241 oid,
1242 commit_hash,
1243 depth,
1244 &page_dir,
1245 tera,
1246 project_name,
1247 lang,
1248 clone_url,
1249 ss,
1250 theme,
1251 )?;
1252 }
1253
1254 Ok(())
1255}
1256
1257#[allow(clippy::too_many_arguments)]
1259fn render_blob_page(
1260 repo_path: &Path,
1261 filename: &str,
1262 file_path: &str, oid: gix::ObjectId,
1264 commit_hash: &str,
1265 depth: usize, page_dir: &Path, tera: &Tera,
1268 project_name: &str,
1269 lang: &Option<String>,
1270 clone_url: &Option<String>,
1271 ss: &syntect::parsing::SyntaxSet,
1272 theme: &syntect::highlighting::Theme,
1273) -> Result<()> {
1274 const MAX_BLOB_BYTES: usize = 1024 * 1024; let data: Vec<u8> = std::process::Command::new("git")
1278 .current_dir(repo_path)
1279 .args(["cat-file", "blob", &oid.to_string()])
1280 .output()
1281 .map(|o| o.stdout)
1282 .unwrap_or_default();
1283
1284 let is_binary = data[..data.len().min(8192)].contains(&0u8);
1285 let too_large = data.len() > MAX_BLOB_BYTES;
1286
1287 let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
1288 None
1289 } else {
1290 let text = String::from_utf8_lossy(&data);
1291 let ext = std::path::Path::new(filename)
1292 .extension()
1293 .and_then(|s| s.to_str())
1294 .unwrap_or("");
1295 let syntax = ss
1296 .find_syntax_by_extension(ext)
1297 .or_else(|| {
1298 text.lines()
1299 .next()
1300 .and_then(|l| ss.find_syntax_by_first_line(l))
1301 })
1302 .unwrap_or_else(|| ss.find_syntax_plain_text());
1303 Some(
1304 syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
1305 .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
1306 )
1307 };
1308
1309 let root_path = "../".repeat(3 + depth);
1310 let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2));
1311 let breadcrumbs = make_crumbs(
1312 std::path::Path::new(file_path)
1313 .parent()
1314 .and_then(|p| p.to_str())
1315 .unwrap_or(""),
1316 true,
1317 Some(filename),
1318 );
1319
1320 let mut ctx = Context::new();
1321 ctx.insert("project_name", project_name);
1322 ctx.insert("lang", lang);
1323 ctx.insert("clone_url", clone_url);
1324 ctx.insert("commit_hash", commit_hash);
1325 ctx.insert("commit_hash_short", &commit_hash[..7]);
1326 ctx.insert("commit_url", &commit_url);
1327 ctx.insert("file_path", file_path);
1328 ctx.insert("filename", filename);
1329 ctx.insert("breadcrumbs", &breadcrumbs);
1330 ctx.insert("content_html", &content_html);
1331 ctx.insert("is_binary", &is_binary);
1332 ctx.insert("too_large", &too_large);
1333 ctx.insert("size", &data.len());
1334 ctx.insert("root_path", &root_path);
1335
1336 let html = tera
1337 .render("git_blob.html", &ctx)
1338 .map_err(|e| miette::miette!("{e}"))?;
1339 std::fs::write(page_dir.join(format!("{filename}.html")), html).into_diagnostic()?;
1340
1341 Ok(())
1342}
1343
1344fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
1349 let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
1350 let depth = parts.len();
1351 let mut crumbs = Vec::new();
1352
1353 let root_url = if depth == 0 && !is_blob {
1355 None } else {
1357 Some(format!("{}index.html", "../".repeat(depth)))
1358 };
1359 crumbs.push(Crumb {
1360 name: "~".to_string(),
1361 url: root_url,
1362 });
1363
1364 for (i, &part) in parts.iter().enumerate() {
1366 let is_last_and_tree = i == depth - 1 && !is_blob;
1367 let url = if is_last_and_tree {
1368 None } else {
1370 let levels_up = depth - i - 1;
1372 Some(format!("{}index.html", "../".repeat(levels_up)))
1373 };
1374 crumbs.push(Crumb {
1375 name: part.to_string(),
1376 url,
1377 });
1378 }
1379
1380 if is_blob {
1382 if let Some(name) = filename {
1383 crumbs.push(Crumb {
1384 name: name.to_string(),
1385 url: None,
1386 });
1387 }
1388 }
1389
1390 crumbs
1391}
1392
1393fn escape_html(s: &str) -> String {
1394 s.replace('&', "&")
1395 .replace('<', "<")
1396 .replace('>', ">")
1397}
1398
1399fn parse_message(raw: &str) -> (String, Option<String>) {
1403 if let Some(idx) = raw.find("\n\n") {
1404 let subject = raw[..idx].trim().to_string();
1405 let body = raw[idx + 2..].trim().to_string();
1406 let body = if body.is_empty() { None } else { Some(body) };
1407 (subject, body)
1408 } else {
1409 (raw.trim().to_string(), None)
1410 }
1411}