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;
25use miette::{IntoDiagnostic, Result};
26
27use crate::cli::{CYAN, GREEN, RED, RESET};
28use serde::Serialize;
29use tera::{Context, Tera};
30use tracing::{info, warn};
31
32use crate::config::{AbbayeConfig, GitUiConfig};
33
34pub const TEMPLATE_GIT_LOG_HTML: &str = include_str!("templates/git_log.html.j2");
37pub const TEMPLATE_GIT_COMMIT_HTML: &str = include_str!("templates/git_commit.html.j2");
38pub const TEMPLATE_GIT_REFS_HTML: &str = include_str!("templates/git_refs.html.j2");
39pub const TEMPLATE_GIT_TREE_HTML: &str = include_str!("templates/git_tree.html.j2");
40pub const TEMPLATE_GIT_BLOB_HTML: &str = include_str!("templates/git_blob.html.j2");
41
42pub const TEMPLATE_GIT_LOG_GEMTEXT: &str = include_str!("templates/git_log.gmi.j2");
43pub const TEMPLATE_GIT_COMMIT_GEMTEXT: &str = include_str!("templates/git_commit.gmi.j2");
44pub const TEMPLATE_GIT_REFS_GEMTEXT: &str = include_str!("templates/git_refs.gmi.j2");
45pub const TEMPLATE_GIT_TREE_GEMTEXT: &str = include_str!("templates/git_tree.gmi.j2");
46pub const TEMPLATE_GIT_BLOB_GEMTEXT: &str = include_str!("templates/git_blob.gmi.j2");
47
48#[derive(Clone, Serialize)]
51struct CommitParent {
52 hash: String,
53 hash_short: String,
54}
55
56#[derive(Clone, Serialize)]
59struct CommitInfo {
60 hash: String,
61 hash_short: String,
62 author_name: String,
63 author_email: String,
64 date_iso: String,
66 date: String,
68 datetime_display: String,
70 subject: String,
72 body: Option<String>,
74 parents: Vec<CommitParent>,
75 ref_badges: Vec<RefBadge>,
77}
78
79#[derive(Serialize)]
80struct RefInfo {
81 name: String,
82 short_name: String,
83 hash: String,
84 hash_short: String,
85}
86
87#[derive(Serialize)]
90#[serde(rename_all = "lowercase")]
91enum DiffLineKind {
92 Header, Hunk, Added, Removed, Context, }
98
99#[derive(Serialize)]
100struct DiffLine {
101 kind: DiffLineKind,
102 content: String,
103}
104
105#[derive(Serialize)]
106struct ChangedFile {
107 path: String,
108 status: String,
110 diff_lines: Vec<DiffLine>,
111}
112
113#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
116#[serde(rename_all = "lowercase")]
117enum RefBadgeKind {
118 Tag,
120 Branch,
121}
122
123#[derive(Clone, Serialize)]
125struct RefBadge {
126 label: String,
127 kind: RefBadgeKind,
128 url: Option<String>,
129}
130
131#[derive(Serialize)]
133struct BranchNav {
134 short_name: String,
135 filename: String,
137 is_current: bool,
138}
139
140#[derive(Clone)]
143struct BranchEntry {
144 short_name: String,
145 filename: String,
147 tip: gix::ObjectId,
148}
149
150fn make_spinner(label: &str) -> ProgressBar {
154 let pb = ProgressBar::new_spinner();
155 pb.set_style(crate::site::spinner_style(false));
156 pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
157 pb.enable_steady_tick(Duration::from_millis(100));
158 pb
159}
160
161enum Progress {
164 Spinner(ProgressBar),
166 Log,
168}
169
170impl Progress {
171 fn new(label: &str) -> Self {
172 if crate::utils::is_interactive() {
173 let pb = make_spinner(label);
174 pb.set_message("starting…");
175 Self::Spinner(pb)
176 } else {
177 info!("[{label}] starting …");
178 Self::Log
179 }
180 }
181
182 fn set_message(&self, msg: impl Into<String>) {
183 match self {
184 Self::Spinner(pb) => pb.set_message(msg.into()),
185 Self::Log => info!("[git ui] {}", msg.into()),
186 }
187 }
188
189 fn finish_done(&self) {
190 match self {
191 Self::Spinner(pb) => {
192 pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
193 }
194 Self::Log => info!("[git ui] done"),
195 }
196 }
197
198 fn finish_failed(&self, detail: &miette::Report) {
199 match self {
200 Self::Spinner(pb) => {
201 pb.finish_with_message(format!("{RED}\u{2717} failed: {detail}{RESET}"));
202 }
203 Self::Log => warn!("[git ui] failed: {detail}"),
204 }
205 }
206}
207
208pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> {
209 let output_dir = &config.site.output_dir;
210 let prefix = &git_cfg.prefix;
211 let prefix_empty = prefix.is_empty();
212 let ui_dir = if prefix_empty {
213 output_dir.clone()
214 } else {
215 output_dir.join(prefix)
216 };
217 let bare_dir = output_dir.join("repository.git");
218
219 let root_path_for = |extra_depth: usize| -> String {
220 if prefix_empty {
221 "../".repeat(extra_depth)
222 } else {
223 "../".repeat(extra_depth + 1)
224 }
225 };
226
227 let git_ui_path = if prefix_empty {
228 String::new()
229 } else {
230 format!("{prefix}/")
231 };
232
233 tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
234 tokio::fs::create_dir_all(ui_dir.join("commit"))
235 .await
236 .into_diagnostic()?;
237
238 let repo_path: PathBuf = git_cfg
239 .repo_path
240 .clone()
241 .unwrap_or_else(|| PathBuf::from("."));
242
243 let max_commits = git_cfg.max_commits;
244 let default_branch = git_cfg.default_branch.clone();
245 let include = build_globset(&git_cfg.include)?;
246 let exclude = build_globset(&git_cfg.exclude)?;
247 let repo_path_clone = repo_path.clone();
248
249 let bare_default_branch = default_branch.clone();
253 let bare_include = include.clone();
254 let bare_exclude = exclude.clone();
255
256 let clone_url = generate_clone_url(config, git_cfg);
259
260 let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
268 tokio::task::spawn_blocking(move || -> Result<_> {
269 let repo = match gix::discover(&repo_path_clone) {
270 Ok(r) => r,
271 Err(e) => {
272 warn!(
273 "git_ui: could not open repository at {}: {e}",
274 repo_path_clone.display()
275 );
276 return Ok((vec![], vec![], vec![], vec![], vec![]));
277 }
278 };
279
280 let branches = collect_branch_entries(&repo, &default_branch, &include, &exclude)?;
281 let (tags, ref_branches) = collect_refs(&repo, &include, &exclude)?;
282 let ref_labels = build_ref_labels(&repo, &include, &exclude)?;
283
284 let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
286 let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();
287
288 for branch in &branches {
289 let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
290 for c in &commits {
291 unique_map
292 .entry(c.hash.clone())
293 .or_insert_with(|| c.clone());
294 }
295 branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
296 }
297
298 let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();
299
300 let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
302 for branch in &branches {
303 seen.insert(branch.tip.to_string(), branch.tip);
304 }
305 let refs_platform = repo.references().into_diagnostic()?;
306 for reference in refs_platform.all().into_diagnostic()? {
307 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
308 let name = reference.name().as_bstr().to_str_lossy().into_owned();
309 if !name.starts_with("refs/tags/") {
310 continue;
311 }
312 let short_name = name.trim_start_matches("refs/tags/");
313 if !ref_is_included(short_name, &include, &exclude) {
314 continue;
315 }
316 if let Ok(id) = reference.peel_to_id() {
317 let hash = id.to_string();
318 seen.entry(hash).or_insert_with(|| id.detach());
319 }
320 }
321 let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();
322
323 Ok((
324 branch_pages,
325 unique_commits,
326 tags,
327 ref_branches,
328 browse_revisions,
329 ))
330 })
331 .await
332 .into_diagnostic()??;
333
334 if branch_pages.is_empty() {
335 return Ok(());
337 }
338
339 let progress = Progress::new("git ui");
340
341 progress.set_message("cloning bare repository…");
343 if let Err(e) = export_bare_clone(
344 &repo_path,
345 &bare_dir,
346 &bare_default_branch,
347 &bare_include,
348 &bare_exclude,
349 )
350 .await
351 {
352 progress.finish_failed(&e);
353 return Err(e);
354 }
355
356 let mut tera = Tera::default();
358 let theme_path = PathBuf::from(".abbaye").join("theme");
359
360 crate::site::register_format_templates(
361 &mut tera,
362 &theme_path,
363 &config.site.formats,
364 &[
365 ("git_log", TEMPLATE_GIT_LOG_HTML, TEMPLATE_GIT_LOG_GEMTEXT),
366 (
367 "git_commit",
368 TEMPLATE_GIT_COMMIT_HTML,
369 TEMPLATE_GIT_COMMIT_GEMTEXT,
370 ),
371 (
372 "git_refs",
373 TEMPLATE_GIT_REFS_HTML,
374 TEMPLATE_GIT_REFS_GEMTEXT,
375 ),
376 ],
377 )?;
378
379 let mut nav_entries: Vec<(String, String)> = branch_pages
383 .iter()
384 .map(|(name, file, _)| (name.clone(), file.clone()))
385 .collect();
386 nav_entries.sort_by(|(na, fa), (nb, fb)| {
387 let a_default = fa == "index.html";
388 let b_default = fb == "index.html";
389 b_default.cmp(&a_default).then(na.cmp(nb))
390 });
391
392 progress.set_message("rendering log pages…");
394 for (short_name, filename, commits) in &branch_pages {
395 let truncated = commits.len() >= max_commits;
396
397 let branch_nav: Vec<BranchNav> = nav_entries
398 .iter()
399 .map(|(bn, bf)| BranchNav {
400 short_name: bn.clone(),
401 filename: bf.clone(),
402 is_current: bn == short_name,
403 })
404 .collect();
405
406 for format in &config.site.formats {
407 let suffix = format.extension();
408 let tmpl_name = format!("git_log.{suffix}");
409 let ext = format.extension();
410 let out_filename = filename.replace(".html", &format!(".{ext}"));
411
412 let mut ctx = Context::new();
413 ctx.insert("project_name", &config.site.name);
414 ctx.insert("lang", &config.site.lang);
415 ctx.insert("clone_url", &clone_url);
416 ctx.insert("current_branch", short_name);
417 ctx.insert("branch_nav", &branch_nav);
418 ctx.insert("commits", commits);
419 ctx.insert("truncated", &truncated);
420 ctx.insert("root_path", &root_path_for(0));
421 ctx.insert("git_ui_path", &git_ui_path);
422
423 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
424 tokio::fs::write(ui_dir.join(&out_filename), content)
425 .await
426 .into_diagnostic()?;
427 }
428 }
429
430 for format in &config.site.formats {
432 let suffix = format.extension();
433 let tmpl_name = format!("git_refs.{suffix}");
434 let ext = format.extension();
435 let out_filename = format!("refs.{ext}");
436
437 let mut ctx = Context::new();
438 ctx.insert("project_name", &config.site.name);
439 ctx.insert("lang", &config.site.lang);
440 ctx.insert("clone_url", &clone_url);
441 ctx.insert("tags", &tags);
442 ctx.insert("branches", &ref_branches);
443 ctx.insert("root_path", &root_path_for(0));
444 ctx.insert("git_ui_path", &git_ui_path);
445
446 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
447 tokio::fs::write(ui_dir.join(&out_filename), content)
448 .await
449 .into_diagnostic()?;
450 }
451
452 progress.set_message(format!("rendering {} commit pages…", unique_commits.len()));
454 for commit_info in &unique_commits {
455 let changed_files = get_changed_files(&commit_info.hash).await?;
456 let has_browse = !commit_info.ref_badges.is_empty();
457 let commit_dir = ui_dir.join("commit");
458
459 for format in &config.site.formats {
460 let suffix = format.extension();
461 let tmpl_name = format!("git_commit.{suffix}");
462 let ext = format.extension();
463 let out_filename = format!("{}.{ext}", commit_info.hash);
464
465 let mut ctx = Context::new();
466 ctx.insert("project_name", &config.site.name);
467 ctx.insert("lang", &config.site.lang);
468 ctx.insert("clone_url", &clone_url);
469 ctx.insert("commit", commit_info);
470 ctx.insert("changed_files", &changed_files);
471 ctx.insert("has_browse", &has_browse);
472 ctx.insert("root_path", &root_path_for(1));
473 ctx.insert("git_ui_path", &git_ui_path);
474
475 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
476 tokio::fs::write(commit_dir.join(&out_filename), content)
477 .await
478 .into_diagnostic()?;
479 }
480 }
481
482 if !browse_revisions.is_empty() {
484 progress.set_message(format!(
485 "building browse pages for {} revision(s)…",
486 browse_revisions.len()
487 ));
488 let browse_dir = ui_dir.join("browse");
489 tokio::fs::create_dir_all(&browse_dir)
490 .await
491 .into_diagnostic()?;
492
493 let project_name = config.site.name.clone();
494 let lang = config.site.lang.clone();
495 let clone_url_browse = clone_url.clone();
496 let theme_path = PathBuf::from(".abbaye").join("theme");
497 let repo_path_browse = repo_path.clone();
498 let formats = config.site.formats.clone();
499 let git_ui_path_browse = git_ui_path.clone();
500 let prefix_empty_browse = prefix_empty;
501
502 let ref_map: Vec<(String, String)> = tags
505 .iter()
506 .chain(ref_branches.iter())
507 .filter_map(|r| {
508 let hash = &r.hash;
509 if browse_revisions.iter().any(|(h, _)| h == hash) {
510 Some((r.short_name.replace('/', "-"), hash.clone()))
511 } else {
512 None
513 }
514 })
515 .collect();
516
517 tokio::task::spawn_blocking(move || {
518 let ref_refs: Vec<(&str, &str)> = ref_map
520 .iter()
521 .map(|(n, h)| (n.as_str(), h.as_str()))
522 .collect();
523 crate::git_browse::build_browse_pages(
524 &browse_revisions,
525 &browse_dir,
526 &repo_path_browse,
527 &theme_path,
528 &project_name,
529 &lang,
530 &clone_url_browse,
531 &formats,
532 &git_ui_path_browse,
533 prefix_empty_browse,
534 &ref_refs,
535 )
536 })
537 .await
538 .into_diagnostic()??
539 }
540
541 progress.finish_done();
542 Ok(())
543}
544
545pub fn generate_clone_url(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
546 git_cfg.clone_url.clone().or_else(|| {
547 config
548 .site
549 .base_url
550 .as_ref()
551 .map(|base| format!("{}/repository.git", base.trim_end_matches('/')))
552 })
553}
554
555fn build_globset(patterns: &[String]) -> Result<GlobSet> {
561 let mut builder = GlobSetBuilder::new();
562 for pattern in patterns {
563 let glob = Glob::new(pattern)
564 .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
565 builder.add(glob);
566 }
567 builder.build().into_diagnostic()
568}
569
570fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
578 let included = include.is_empty() || include.is_match(short_name);
579 included && !exclude.is_match(short_name)
580}
581
582fn collect_branch_entries(
594 repo: &gix::Repository,
595 default_branch: &str,
596 include: &GlobSet,
597 exclude: &GlobSet,
598) -> Result<Vec<BranchEntry>> {
599 let mut entries: Vec<BranchEntry> = Vec::new();
600
601 let refs_platform = repo.references().into_diagnostic()?;
602 for reference in refs_platform.all().into_diagnostic()? {
603 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
604 let name = reference.name().as_bstr().to_str_lossy().into_owned();
605
606 if !name.starts_with("refs/heads/") {
607 continue;
608 }
609
610 let short_name = name.trim_start_matches("refs/heads/").to_string();
611 if !ref_is_included(&short_name, include, exclude) {
612 continue;
613 }
614 let tip = match reference.peel_to_id() {
615 Ok(id) => id.detach(),
616 Err(_) => continue,
617 };
618
619 let filename = format!("{}.html", short_name.replace('/', "-"));
621 entries.push(BranchEntry {
622 short_name,
623 filename,
624 tip,
625 });
626 }
627
628 entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
630
631 if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
633 e.filename = "index.html".to_string();
634 } else if let Some(first) = entries.first_mut() {
635 warn!(
636 "git_ui: default branch '{}' not found; using '{}' as index.html",
637 default_branch, first.short_name
638 );
639 first.filename = "index.html".to_string();
640 }
641
642 Ok(entries)
643}
644
645fn build_ref_labels(
651 repo: &gix::Repository,
652 include: &GlobSet,
653 exclude: &GlobSet,
654) -> Result<HashMap<String, Vec<RefBadge>>> {
655 let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
656
657 let refs_platform = repo.references().into_diagnostic()?;
658 for reference in refs_platform.all().into_diagnostic()? {
659 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
660 let name = reference.name().as_bstr().to_str_lossy().into_owned();
661
662 if name == "HEAD" || name.starts_with("refs/remotes/") {
663 continue;
664 }
665
666 let hash = match reference.peel_to_id() {
667 Ok(id) => id.to_string(),
668 Err(_) => continue,
669 };
670
671 let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
672 if !ref_is_included(label, include, exclude) {
673 continue;
674 }
675 RefBadge {
676 label: label.to_string(),
677 kind: RefBadgeKind::Tag,
678 url: Some(format!("browse/{}/", label)),
679 }
680 } else if let Some(label) = name.strip_prefix("refs/heads/") {
681 if !ref_is_included(label, include, exclude) {
682 continue;
683 }
684 RefBadge {
685 label: label.to_string(),
686 kind: RefBadgeKind::Branch,
687 url: Some(format!("browse/{}/", label)),
688 }
689 } else {
690 continue;
691 };
692
693 map.entry(hash).or_default().push(badge);
694 }
695
696 for badges in map.values_mut() {
698 badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
699 }
700
701 Ok(map)
702}
703
704fn collect_commits(
706 repo: &gix::Repository,
707 tip: gix::ObjectId,
708 max: usize,
709 ref_labels: &HashMap<String, Vec<RefBadge>>,
710) -> Result<Vec<CommitInfo>> {
711 let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
712 let mut commits = Vec::new();
713
714 for info in walk.take(max) {
715 let info = info.into_diagnostic()?;
716 let id = info.id;
717
718 let object = repo.find_object(id).into_diagnostic()?;
719 let commit = object.into_commit();
720 let decoded = commit.decode().into_diagnostic()?;
721
722 let author = decoded.author().into_diagnostic()?;
723 let author_name = author.name.to_str_lossy().into_owned();
724 let author_email = author.email.to_str_lossy().into_owned();
725 let unix_secs: i64 = author
726 .time
727 .split_whitespace()
728 .next()
729 .and_then(|s| s.parse().ok())
730 .unwrap_or(0);
731
732 let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
733 let date = dt.format("%Y-%m-%d").to_string();
734 let date_iso = dt.to_rfc3339();
735 let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
736
737 let raw_msg = decoded.message.to_str_lossy();
738 let (subject, body) = parse_message(&raw_msg);
739
740 let hash = id.to_string();
741 let hash_short = hash[..7].to_string();
742
743 let parents = info
744 .parent_ids
745 .iter()
746 .map(|p| {
747 let h = p.to_string();
748 let hs = h[..7].to_string();
749 CommitParent {
750 hash: h,
751 hash_short: hs,
752 }
753 })
754 .collect();
755
756 let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
757
758 commits.push(CommitInfo {
759 hash,
760 hash_short,
761 author_name,
762 author_email,
763 date,
764 date_iso,
765 datetime_display,
766 subject,
767 body,
768 parents,
769 ref_badges,
770 });
771 }
772
773 Ok(commits)
774}
775
776fn collect_refs(
781 repo: &gix::Repository,
782 include: &GlobSet,
783 exclude: &GlobSet,
784) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
785 let mut tags: Vec<RefInfo> = Vec::new();
786 let mut branches: Vec<RefInfo> = Vec::new();
787
788 let refs_platform = repo.references().into_diagnostic()?;
789 for reference in refs_platform.all().into_diagnostic()? {
790 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
791 let name = reference.name().as_bstr().to_str_lossy().into_owned();
792
793 if name.starts_with("refs/remotes/") || name == "HEAD" {
794 continue;
795 }
796
797 let hash = match reference.peel_to_id() {
798 Ok(id) => id.to_string(),
799 Err(_) => continue,
800 };
801 let hash_short = hash[..7.min(hash.len())].to_string();
802
803 if name.starts_with("refs/tags/") {
804 let short_name = name.trim_start_matches("refs/tags/").to_string();
805 if !ref_is_included(&short_name, include, exclude) {
806 continue;
807 }
808 tags.push(RefInfo {
809 name,
810 short_name,
811 hash,
812 hash_short,
813 });
814 } else if name.starts_with("refs/heads/") {
815 let short_name = name.trim_start_matches("refs/heads/").to_string();
816 if !ref_is_included(&short_name, include, exclude) {
817 continue;
818 }
819 branches.push(RefInfo {
820 name,
821 short_name,
822 hash,
823 hash_short,
824 });
825 }
826 }
827
828 tags.sort_by(|a, b| b.name.cmp(&a.name));
830 branches.sort_by(|a, b| a.name.cmp(&b.name));
831
832 Ok((tags, branches))
833}
834
835async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
838 let output = tokio::process::Command::new("git")
839 .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
840 .output()
841 .await
842 .into_diagnostic()?;
843
844 if !output.status.success() {
845 return Ok(vec![]);
846 }
847
848 Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
849}
850
851fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
859 let mut files: Vec<ChangedFile> = Vec::new();
860 let mut cur_lines: Vec<DiffLine> = Vec::new();
861 let mut cur_path = String::new();
862 let mut cur_status = "modified";
863
864 let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
865 lines.push(DiffLine {
866 kind,
867 content: content.to_string(),
868 });
869 };
870
871 for line in text.lines() {
872 if line.starts_with("diff --git ") {
873 if !cur_path.is_empty() {
874 files.push(ChangedFile {
875 path: cur_path.clone(),
876 status: cur_status.to_string(),
877 diff_lines: std::mem::take(&mut cur_lines),
878 });
879 }
880 cur_path = line
883 .rsplit_once(" b/")
884 .map(|(_, p)| p.to_string())
885 .unwrap_or_default();
886 cur_status = "modified";
887 push_line(&mut cur_lines, DiffLineKind::Header, line);
888 } else if line.starts_with("new file mode") {
889 cur_status = "added";
890 push_line(&mut cur_lines, DiffLineKind::Header, line);
891 } else if line.starts_with("deleted file mode") {
892 cur_status = "deleted";
893 push_line(&mut cur_lines, DiffLineKind::Header, line);
894 } else if line.starts_with("rename from") || line.starts_with("rename to") {
895 cur_status = "renamed";
896 push_line(&mut cur_lines, DiffLineKind::Header, line);
897 } else if line.starts_with("similarity index")
898 || line.starts_with("copy from")
899 || line.starts_with("copy to")
900 || line.starts_with("index ")
901 || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("Binary files")
904 || line.starts_with('\\')
905 {
906 push_line(&mut cur_lines, DiffLineKind::Header, line);
907 } else if line.starts_with("@@") {
908 push_line(&mut cur_lines, DiffLineKind::Hunk, line);
909 } else if line.starts_with('+') {
910 push_line(&mut cur_lines, DiffLineKind::Added, line);
911 } else if line.starts_with('-') {
912 push_line(&mut cur_lines, DiffLineKind::Removed, line);
913 } else {
914 push_line(&mut cur_lines, DiffLineKind::Context, line);
915 }
916 }
917
918 if !cur_path.is_empty() {
919 files.push(ChangedFile {
920 path: cur_path,
921 status: cur_status.to_string(),
922 diff_lines: cur_lines,
923 });
924 }
925
926 files
927}
928
929async fn export_bare_clone(
938 source: &Path,
939 dest: &Path,
940 default_branch: &str,
941 include: &GlobSet,
942 exclude: &GlobSet,
943) -> Result<()> {
944 use tokio::process::Command;
945
946 if dest.exists() {
947 tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
948 }
949
950 let status = Command::new("git")
951 .arg("clone")
952 .arg("--bare")
953 .arg(source)
954 .arg(dest)
955 .stdout(std::process::Stdio::null())
956 .stderr(std::process::Stdio::null())
957 .status()
958 .await
959 .into_diagnostic()?;
960
961 if !status.success() {
962 return Err(miette::miette!(
963 "git clone --bare failed with exit status {status}"
964 ));
965 }
966
967 prune_excluded_refs(dest, default_branch, include, exclude).await?;
968
969 let status = Command::new("git")
971 .arg("-C")
972 .arg(dest)
973 .arg("update-server-info")
974 .stdout(std::process::Stdio::null())
975 .stderr(std::process::Stdio::null())
976 .status()
977 .await
978 .into_diagnostic()?;
979
980 if !status.success() {
981 warn!("git update-server-info failed; dumb HTTP cloning may not work");
982 }
983
984 Ok(())
985}
986
987async fn prune_excluded_refs(
995 dest: &Path,
996 default_branch: &str,
997 include: &GlobSet,
998 exclude: &GlobSet,
999) -> Result<()> {
1000 use tokio::process::Command;
1001
1002 if include.is_empty() && exclude.is_empty() {
1003 return Ok(());
1004 }
1005
1006 let output = Command::new("git")
1007 .arg("-C")
1008 .arg(dest)
1009 .arg("for-each-ref")
1010 .arg("--format=%(refname)")
1011 .arg("refs/heads")
1012 .arg("refs/tags")
1013 .output()
1014 .await
1015 .into_diagnostic()?;
1016
1017 if !output.status.success() {
1018 warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
1019 return Ok(());
1020 }
1021
1022 let refnames = String::from_utf8_lossy(&output.stdout);
1023 let mut removed_any = false;
1024 let mut kept_branches: Vec<String> = Vec::new();
1025
1026 for refname in refnames.lines() {
1027 let short_name = match refname
1028 .strip_prefix("refs/heads/")
1029 .or_else(|| refname.strip_prefix("refs/tags/"))
1030 {
1031 Some(s) => s,
1032 None => continue,
1033 };
1034
1035 if ref_is_included(short_name, include, exclude) {
1036 if refname.starts_with("refs/heads/") {
1037 kept_branches.push(short_name.to_string());
1038 }
1039 continue;
1040 }
1041
1042 let status = Command::new("git")
1043 .arg("-C")
1044 .arg(dest)
1045 .arg("update-ref")
1046 .arg("-d")
1047 .arg(refname)
1048 .stdout(std::process::Stdio::null())
1049 .stderr(std::process::Stdio::null())
1050 .status()
1051 .await
1052 .into_diagnostic()?;
1053
1054 if status.success() {
1055 removed_any = true;
1056 } else {
1057 warn!("failed to delete excluded ref '{refname}' from repository.git");
1058 }
1059 }
1060
1061 if !removed_any {
1062 return Ok(());
1063 }
1064
1065 let new_head = if kept_branches.iter().any(|b| b == default_branch) {
1070 Some(default_branch.to_string())
1071 } else if let Some(first) = kept_branches.first() {
1072 warn!(
1073 "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
1074 default_branch, first
1075 );
1076 Some(first.clone())
1077 } else {
1078 warn!(
1079 "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
1080 );
1081 None
1082 };
1083
1084 if let Some(branch) = new_head {
1085 let status = Command::new("git")
1086 .arg("-C")
1087 .arg(dest)
1088 .arg("symbolic-ref")
1089 .arg("HEAD")
1090 .arg(format!("refs/heads/{branch}"))
1091 .stdout(std::process::Stdio::null())
1092 .stderr(std::process::Stdio::null())
1093 .status()
1094 .await
1095 .into_diagnostic()?;
1096
1097 if !status.success() {
1098 warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
1099 }
1100 }
1101
1102 let status = Command::new("git")
1105 .arg("-C")
1106 .arg(dest)
1107 .arg("gc")
1108 .arg("--prune=now")
1109 .arg("--quiet")
1110 .status()
1111 .await
1112 .into_diagnostic()?;
1113
1114 if !status.success() {
1115 warn!(
1116 "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
1117 );
1118 }
1119
1120 Ok(())
1121}
1122
1123fn parse_message(raw: &str) -> (String, Option<String>) {
1127 if let Some(idx) = raw.find("\n\n") {
1128 let subject = raw[..idx].trim().to_string();
1129 let body = raw[idx + 2..].trim().to_string();
1130 let body = if body.is_empty() { None } else { Some(body) };
1131 (subject, body)
1132 } else {
1133 (raw.trim().to_string(), None)
1134 }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140
1141 #[test]
1142 fn test_generate_clone_url_custom() {
1143 let config = AbbayeConfig::default();
1144 let git_cfg = GitUiConfig {
1145 clone_url: Some("https://git.example.com/repo.git".into()),
1146 ..Default::default()
1147 };
1148 let url = generate_clone_url(&config, &git_cfg);
1149 assert_eq!(url.as_deref(), Some("https://git.example.com/repo.git"));
1150 }
1151
1152 #[test]
1153 fn test_generate_clone_url_derived() {
1154 let config = AbbayeConfig {
1155 site: crate::config::SiteConfig {
1156 name: String::new(),
1157 base_url: Some("https://example.com".into()),
1158 ..Default::default()
1159 },
1160 ..Default::default()
1161 };
1162 let git_cfg = GitUiConfig::default();
1163 let url = generate_clone_url(&config, &git_cfg);
1164 assert_eq!(url.as_deref(), Some("https://example.com/repository.git"));
1165 }
1166
1167 #[test]
1168 fn test_generate_clone_url_neither() {
1169 let config = AbbayeConfig::default();
1170 let git_cfg = GitUiConfig::default();
1171 let url = generate_clone_url(&config, &git_cfg);
1172 assert!(url.is_none());
1173 }
1174
1175 #[test]
1176 fn test_parse_message_subject_only() {
1177 let (subj, body) = parse_message("fix: a bug");
1178 assert_eq!(subj, "fix: a bug");
1179 assert!(body.is_none());
1180 }
1181
1182 #[test]
1183 fn test_parse_message_subject_and_body() {
1184 let (subj, body) = parse_message("feat: add widget\n\nThis is a long description.");
1185 assert_eq!(subj, "feat: add widget");
1186 assert_eq!(body.as_deref(), Some("This is a long description."));
1187 }
1188
1189 #[test]
1190 fn test_parse_message_empty_body() {
1191 let (subj, body) = parse_message("chore: bump\n\n ");
1192 assert_eq!(subj, "chore: bump");
1193 assert!(body.is_none());
1194 }
1195
1196 #[test]
1197 fn test_parse_message_multi_paragraph() {
1198 let msg = "major: breaking\n\nFirst paragraph.\n\nSecond paragraph.";
1199 let (subj, body) = parse_message(msg);
1200 assert_eq!(subj, "major: breaking");
1201 assert_eq!(
1202 body.as_deref(),
1203 Some("First paragraph.\n\nSecond paragraph.")
1204 );
1205 }
1206
1207 #[test]
1208 fn test_parse_diff_output_empty() {
1209 let files = parse_diff_output("");
1210 assert!(files.is_empty());
1211 }
1212
1213 #[test]
1214 fn test_parse_diff_output_single_file() {
1215 let diff = "\
1216diff --git a/src/main.rs b/src/main.rs
1217new file mode 100644
1218index 0000000..e69de29
1219--- /dev/null
1220+++ b/src/main.rs
1221@@ -0,0 +1 @@
1222+hello
1223";
1224 let files = parse_diff_output(diff);
1225 assert_eq!(files.len(), 1);
1226 assert_eq!(files[0].path, "src/main.rs");
1227 assert_eq!(files[0].status, "added");
1228 assert!(files[0].diff_lines.iter().any(|l| l.content == "+hello"));
1229 }
1230
1231 #[test]
1232 fn test_parse_diff_output_multiple_files() {
1233 let diff = "\
1234diff --git a/a.txt b/a.txt
1235deleted file mode 100644
1236--- a/a.txt
1237+++ /dev/null
1238@@ -1 +0,0 @@
1239-abc
1240diff --git a/b.txt b/b.txt
1241new file mode 100644
1242--- /dev/null
1243+++ b/b.txt
1244@@ -0,0 +1 @@
1245+xyz
1246";
1247 let files = parse_diff_output(diff);
1248 assert_eq!(files.len(), 2);
1249 assert_eq!(files[0].path, "a.txt");
1250 assert_eq!(files[0].status, "deleted");
1251 assert_eq!(files[1].path, "b.txt");
1252 assert_eq!(files[1].status, "added");
1253 }
1254
1255 #[test]
1256 fn test_parse_diff_output_rename() {
1257 let diff = "\
1258diff --git a/old.rs b/new.rs
1259rename from old.rs
1260rename to new.rs
1261";
1262 let files = parse_diff_output(diff);
1263 assert_eq!(files.len(), 1);
1264 assert_eq!(files[0].status, "renamed");
1265 assert_eq!(files[0].path, "new.rs");
1266 }
1267
1268 #[test]
1269 fn test_parse_diff_output_binary() {
1270 let diff = "\
1271diff --git a/data.bin b/data.bin
1272Binary files a/data.bin and b/data.bin differ
1273";
1274 let files = parse_diff_output(diff);
1275 assert_eq!(files.len(), 1);
1276 assert_eq!(files[0].path, "data.bin");
1277 assert!(
1278 files[0]
1279 .diff_lines
1280 .iter()
1281 .any(|l| l.content.contains("Binary"))
1282 );
1283 }
1284}