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}
129
130#[derive(Serialize)]
132struct BranchNav {
133 short_name: String,
134 filename: String,
136 is_current: bool,
137}
138
139#[derive(Clone)]
142struct BranchEntry {
143 short_name: String,
144 filename: String,
146 tip: gix::ObjectId,
147}
148
149fn make_spinner(label: &str) -> ProgressBar {
153 let pb = ProgressBar::new_spinner();
154 pb.set_style(crate::site::spinner_style(false));
155 pb.set_prefix(format!("{CYAN}[{label}]{RESET}"));
156 pb.enable_steady_tick(Duration::from_millis(100));
157 pb
158}
159
160enum Progress {
163 Spinner(ProgressBar),
165 Log,
167}
168
169impl Progress {
170 fn new(label: &str) -> Self {
171 if crate::utils::is_interactive() {
172 let pb = make_spinner(label);
173 pb.set_message("starting…");
174 Self::Spinner(pb)
175 } else {
176 info!("[{label}] starting …");
177 Self::Log
178 }
179 }
180
181 fn set_message(&self, msg: impl Into<String>) {
182 match self {
183 Self::Spinner(pb) => pb.set_message(msg.into()),
184 Self::Log => info!("[git ui] {}", msg.into()),
185 }
186 }
187
188 fn finish_done(&self) {
189 match self {
190 Self::Spinner(pb) => {
191 pb.finish_with_message(format!("{GREEN}\u{2713} done{RESET}"));
192 }
193 Self::Log => info!("[git ui] done"),
194 }
195 }
196
197 fn finish_failed(&self, detail: &miette::Report) {
198 match self {
199 Self::Spinner(pb) => {
200 pb.finish_with_message(format!("{RED}\u{2717} failed: {detail}{RESET}"));
201 }
202 Self::Log => warn!("[git ui] failed: {detail}"),
203 }
204 }
205}
206
207pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> {
208 let output_dir = &config.site.output_dir;
209 let ui_dir = output_dir.join("repository");
210 let bare_dir = output_dir.join("repository.git");
211
212 tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
213 tokio::fs::create_dir_all(ui_dir.join("commit"))
214 .await
215 .into_diagnostic()?;
216
217 let repo_path: PathBuf = git_cfg
218 .repo_path
219 .clone()
220 .unwrap_or_else(|| PathBuf::from("."));
221
222 let max_commits = git_cfg.max_commits;
223 let default_branch = git_cfg.default_branch.clone();
224 let include = build_globset(&git_cfg.include)?;
225 let exclude = build_globset(&git_cfg.exclude)?;
226 let repo_path_clone = repo_path.clone();
227
228 let bare_default_branch = default_branch.clone();
232 let bare_include = include.clone();
233 let bare_exclude = exclude.clone();
234
235 let clone_url = generate_clone_command(config, git_cfg);
238
239 let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
247 tokio::task::spawn_blocking(move || -> Result<_> {
248 let repo = match gix::discover(&repo_path_clone) {
249 Ok(r) => r,
250 Err(e) => {
251 warn!(
252 "git_ui: could not open repository at {}: {e}",
253 repo_path_clone.display()
254 );
255 return Ok((vec![], vec![], vec![], vec![], vec![]));
256 }
257 };
258
259 let branches = collect_branch_entries(&repo, &default_branch, &include, &exclude)?;
260 let (tags, ref_branches) = collect_refs(&repo, &include, &exclude)?;
261 let ref_labels = build_ref_labels(&repo, &include, &exclude)?;
262
263 let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
265 let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();
266
267 for branch in &branches {
268 let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
269 for c in &commits {
270 unique_map
271 .entry(c.hash.clone())
272 .or_insert_with(|| c.clone());
273 }
274 branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
275 }
276
277 let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();
278
279 let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
281 for branch in &branches {
282 seen.insert(branch.tip.to_string(), branch.tip);
283 }
284 let refs_platform = repo.references().into_diagnostic()?;
285 for reference in refs_platform.all().into_diagnostic()? {
286 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
287 let name = reference.name().as_bstr().to_str_lossy().into_owned();
288 if !name.starts_with("refs/tags/") {
289 continue;
290 }
291 let short_name = name.trim_start_matches("refs/tags/");
292 if !ref_is_included(short_name, &include, &exclude) {
293 continue;
294 }
295 if let Ok(id) = reference.peel_to_id() {
296 let hash = id.to_string();
297 seen.entry(hash).or_insert_with(|| id.detach());
298 }
299 }
300 let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();
301
302 Ok((
303 branch_pages,
304 unique_commits,
305 tags,
306 ref_branches,
307 browse_revisions,
308 ))
309 })
310 .await
311 .into_diagnostic()??;
312
313 if branch_pages.is_empty() {
314 return Ok(());
316 }
317
318 let progress = Progress::new("git ui");
319
320 progress.set_message("cloning bare repository…");
322 if let Err(e) = export_bare_clone(
323 &repo_path,
324 &bare_dir,
325 &bare_default_branch,
326 &bare_include,
327 &bare_exclude,
328 )
329 .await
330 {
331 progress.finish_failed(&e);
332 return Err(e);
333 }
334
335 let mut tera = Tera::default();
337 let theme_path = PathBuf::from(".abbaye").join("theme");
338
339 crate::site::register_format_templates(
340 &mut tera,
341 &theme_path,
342 &config.site.formats,
343 &[
344 ("git_log", TEMPLATE_GIT_LOG_HTML, TEMPLATE_GIT_LOG_GEMTEXT),
345 (
346 "git_commit",
347 TEMPLATE_GIT_COMMIT_HTML,
348 TEMPLATE_GIT_COMMIT_GEMTEXT,
349 ),
350 (
351 "git_refs",
352 TEMPLATE_GIT_REFS_HTML,
353 TEMPLATE_GIT_REFS_GEMTEXT,
354 ),
355 ],
356 )?;
357
358 let mut nav_entries: Vec<(String, String)> = branch_pages
362 .iter()
363 .map(|(name, file, _)| (name.clone(), file.clone()))
364 .collect();
365 nav_entries.sort_by(|(na, fa), (nb, fb)| {
366 let a_default = fa == "index.html";
367 let b_default = fb == "index.html";
368 b_default.cmp(&a_default).then(na.cmp(nb))
369 });
370
371 progress.set_message("rendering log pages…");
373 for (short_name, filename, commits) in &branch_pages {
374 let truncated = commits.len() >= max_commits;
375
376 let branch_nav: Vec<BranchNav> = nav_entries
377 .iter()
378 .map(|(bn, bf)| BranchNav {
379 short_name: bn.clone(),
380 filename: bf.clone(),
381 is_current: bn == short_name,
382 })
383 .collect();
384
385 for format in &config.site.formats {
386 let suffix = format.extension();
387 let tmpl_name = format!("git_log.{suffix}");
388 let ext = format.extension();
389 let out_filename = filename.replace(".html", &format!(".{ext}"));
390
391 let mut ctx = Context::new();
392 ctx.insert("project_name", &config.site.name);
393 ctx.insert("lang", &config.site.lang);
394 ctx.insert("clone_url", &clone_url);
395 ctx.insert("current_branch", short_name);
396 ctx.insert("branch_nav", &branch_nav);
397 ctx.insert("commits", commits);
398 ctx.insert("truncated", &truncated);
399 ctx.insert("root_path", "../");
400
401 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
402 tokio::fs::write(ui_dir.join(&out_filename), content)
403 .await
404 .into_diagnostic()?;
405 }
406 }
407
408 for format in &config.site.formats {
410 let suffix = format.extension();
411 let tmpl_name = format!("git_refs.{suffix}");
412 let ext = format.extension();
413 let out_filename = format!("refs.{ext}");
414
415 let mut ctx = Context::new();
416 ctx.insert("project_name", &config.site.name);
417 ctx.insert("lang", &config.site.lang);
418 ctx.insert("clone_url", &clone_url);
419 ctx.insert("tags", &tags);
420 ctx.insert("branches", &ref_branches);
421 ctx.insert("root_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 progress.set_message(format!("rendering {} commit pages…", unique_commits.len()));
431 for commit_info in &unique_commits {
432 let changed_files = get_changed_files(&commit_info.hash).await?;
433 let has_browse = !commit_info.ref_badges.is_empty();
434 let commit_dir = ui_dir.join("commit");
435
436 for format in &config.site.formats {
437 let suffix = format.extension();
438 let tmpl_name = format!("git_commit.{suffix}");
439 let ext = format.extension();
440 let out_filename = format!("{}.{ext}", commit_info.hash);
441
442 let mut ctx = Context::new();
443 ctx.insert("project_name", &config.site.name);
444 ctx.insert("lang", &config.site.lang);
445 ctx.insert("clone_url", &clone_url);
446 ctx.insert("commit", commit_info);
447 ctx.insert("changed_files", &changed_files);
448 ctx.insert("has_browse", &has_browse);
449 ctx.insert("root_path", "../../");
450
451 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
452 tokio::fs::write(commit_dir.join(&out_filename), content)
453 .await
454 .into_diagnostic()?;
455 }
456 }
457
458 if !browse_revisions.is_empty() {
460 progress.set_message(format!(
461 "building browse pages for {} revision(s)…",
462 browse_revisions.len()
463 ));
464 let browse_dir = ui_dir.join("browse");
465 tokio::fs::create_dir_all(&browse_dir)
466 .await
467 .into_diagnostic()?;
468
469 let project_name = config.site.name.clone();
470 let lang = config.site.lang.clone();
471 let clone_url_browse = clone_url.clone();
472 let theme_path = PathBuf::from(".abbaye").join("theme");
473 let repo_path_browse = repo_path.clone();
474 let formats = config.site.formats.clone();
475
476 tokio::task::spawn_blocking(move || {
477 crate::git_browse::build_browse_pages(
478 &browse_revisions,
479 &browse_dir,
480 &repo_path_browse,
481 &theme_path,
482 &project_name,
483 &lang,
484 &clone_url_browse,
485 &formats,
486 )
487 })
488 .await
489 .into_diagnostic()??
490 }
491
492 progress.finish_done();
493 Ok(())
494}
495
496pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
497 let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
498 config.site.base_url.as_ref().map(|base| {
499 format!(
500 "{}/repository.git {}",
501 base.trim_end_matches('/'),
502 if config.site.name.contains(" ") {
503 format!("'{}'", config.site.name)
504 } else {
505 config.site.name.clone()
506 }
507 )
508 })
509 });
510 clone_url
511}
512
513fn build_globset(patterns: &[String]) -> Result<GlobSet> {
519 let mut builder = GlobSetBuilder::new();
520 for pattern in patterns {
521 let glob = Glob::new(pattern)
522 .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
523 builder.add(glob);
524 }
525 builder.build().into_diagnostic()
526}
527
528fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
536 let included = include.is_empty() || include.is_match(short_name);
537 included && !exclude.is_match(short_name)
538}
539
540fn collect_branch_entries(
552 repo: &gix::Repository,
553 default_branch: &str,
554 include: &GlobSet,
555 exclude: &GlobSet,
556) -> Result<Vec<BranchEntry>> {
557 let mut entries: Vec<BranchEntry> = Vec::new();
558
559 let refs_platform = repo.references().into_diagnostic()?;
560 for reference in refs_platform.all().into_diagnostic()? {
561 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
562 let name = reference.name().as_bstr().to_str_lossy().into_owned();
563
564 if !name.starts_with("refs/heads/") {
565 continue;
566 }
567
568 let short_name = name.trim_start_matches("refs/heads/").to_string();
569 if !ref_is_included(&short_name, include, exclude) {
570 continue;
571 }
572 let tip = match reference.peel_to_id() {
573 Ok(id) => id.detach(),
574 Err(_) => continue,
575 };
576
577 let filename = format!("{}.html", short_name.replace('/', "-"));
579 entries.push(BranchEntry {
580 short_name,
581 filename,
582 tip,
583 });
584 }
585
586 entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
588
589 if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
591 e.filename = "index.html".to_string();
592 } else if let Some(first) = entries.first_mut() {
593 warn!(
594 "git_ui: default branch '{}' not found; using '{}' as index.html",
595 default_branch, first.short_name
596 );
597 first.filename = "index.html".to_string();
598 }
599
600 Ok(entries)
601}
602
603fn build_ref_labels(
609 repo: &gix::Repository,
610 include: &GlobSet,
611 exclude: &GlobSet,
612) -> Result<HashMap<String, Vec<RefBadge>>> {
613 let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
614
615 let refs_platform = repo.references().into_diagnostic()?;
616 for reference in refs_platform.all().into_diagnostic()? {
617 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
618 let name = reference.name().as_bstr().to_str_lossy().into_owned();
619
620 if name == "HEAD" || name.starts_with("refs/remotes/") {
621 continue;
622 }
623
624 let hash = match reference.peel_to_id() {
625 Ok(id) => id.to_string(),
626 Err(_) => continue,
627 };
628
629 let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
630 if !ref_is_included(label, include, exclude) {
631 continue;
632 }
633 RefBadge {
634 label: label.to_string(),
635 kind: RefBadgeKind::Tag,
636 }
637 } else if let Some(label) = name.strip_prefix("refs/heads/") {
638 if !ref_is_included(label, include, exclude) {
639 continue;
640 }
641 RefBadge {
642 label: label.to_string(),
643 kind: RefBadgeKind::Branch,
644 }
645 } else {
646 continue;
647 };
648
649 map.entry(hash).or_default().push(badge);
650 }
651
652 for badges in map.values_mut() {
654 badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
655 }
656
657 Ok(map)
658}
659
660fn collect_commits(
662 repo: &gix::Repository,
663 tip: gix::ObjectId,
664 max: usize,
665 ref_labels: &HashMap<String, Vec<RefBadge>>,
666) -> Result<Vec<CommitInfo>> {
667 let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
668 let mut commits = Vec::new();
669
670 for info in walk.take(max) {
671 let info = info.into_diagnostic()?;
672 let id = info.id;
673
674 let object = repo.find_object(id).into_diagnostic()?;
675 let commit = object.into_commit();
676 let decoded = commit.decode().into_diagnostic()?;
677
678 let author = decoded.author().into_diagnostic()?;
679 let author_name = author.name.to_str_lossy().into_owned();
680 let author_email = author.email.to_str_lossy().into_owned();
681 let unix_secs: i64 = author
682 .time
683 .split_whitespace()
684 .next()
685 .and_then(|s| s.parse().ok())
686 .unwrap_or(0);
687
688 let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
689 let date = dt.format("%Y-%m-%d").to_string();
690 let date_iso = dt.to_rfc3339();
691 let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
692
693 let raw_msg = decoded.message.to_str_lossy();
694 let (subject, body) = parse_message(&raw_msg);
695
696 let hash = id.to_string();
697 let hash_short = hash[..7].to_string();
698
699 let parents = info
700 .parent_ids
701 .iter()
702 .map(|p| {
703 let h = p.to_string();
704 let hs = h[..7].to_string();
705 CommitParent {
706 hash: h,
707 hash_short: hs,
708 }
709 })
710 .collect();
711
712 let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
713
714 commits.push(CommitInfo {
715 hash,
716 hash_short,
717 author_name,
718 author_email,
719 date,
720 date_iso,
721 datetime_display,
722 subject,
723 body,
724 parents,
725 ref_badges,
726 });
727 }
728
729 Ok(commits)
730}
731
732fn collect_refs(
737 repo: &gix::Repository,
738 include: &GlobSet,
739 exclude: &GlobSet,
740) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
741 let mut tags: Vec<RefInfo> = Vec::new();
742 let mut branches: Vec<RefInfo> = Vec::new();
743
744 let refs_platform = repo.references().into_diagnostic()?;
745 for reference in refs_platform.all().into_diagnostic()? {
746 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
747 let name = reference.name().as_bstr().to_str_lossy().into_owned();
748
749 if name.starts_with("refs/remotes/") || name == "HEAD" {
750 continue;
751 }
752
753 let hash = match reference.peel_to_id() {
754 Ok(id) => id.to_string(),
755 Err(_) => continue,
756 };
757 let hash_short = hash[..7.min(hash.len())].to_string();
758
759 if name.starts_with("refs/tags/") {
760 let short_name = name.trim_start_matches("refs/tags/").to_string();
761 if !ref_is_included(&short_name, include, exclude) {
762 continue;
763 }
764 tags.push(RefInfo {
765 name,
766 short_name,
767 hash,
768 hash_short,
769 });
770 } else if name.starts_with("refs/heads/") {
771 let short_name = name.trim_start_matches("refs/heads/").to_string();
772 if !ref_is_included(&short_name, include, exclude) {
773 continue;
774 }
775 branches.push(RefInfo {
776 name,
777 short_name,
778 hash,
779 hash_short,
780 });
781 }
782 }
783
784 tags.sort_by(|a, b| b.name.cmp(&a.name));
786 branches.sort_by(|a, b| a.name.cmp(&b.name));
787
788 Ok((tags, branches))
789}
790
791async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
794 let output = tokio::process::Command::new("git")
795 .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
796 .output()
797 .await
798 .into_diagnostic()?;
799
800 if !output.status.success() {
801 return Ok(vec![]);
802 }
803
804 Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
805}
806
807fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
815 let mut files: Vec<ChangedFile> = Vec::new();
816 let mut cur_lines: Vec<DiffLine> = Vec::new();
817 let mut cur_path = String::new();
818 let mut cur_status = "modified";
819
820 let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
821 lines.push(DiffLine {
822 kind,
823 content: content.to_string(),
824 });
825 };
826
827 for line in text.lines() {
828 if line.starts_with("diff --git ") {
829 if !cur_path.is_empty() {
830 files.push(ChangedFile {
831 path: cur_path.clone(),
832 status: cur_status.to_string(),
833 diff_lines: std::mem::take(&mut cur_lines),
834 });
835 }
836 cur_path = line
839 .rsplit_once(" b/")
840 .map(|(_, p)| p.to_string())
841 .unwrap_or_default();
842 cur_status = "modified";
843 push_line(&mut cur_lines, DiffLineKind::Header, line);
844 } else if line.starts_with("new file mode") {
845 cur_status = "added";
846 push_line(&mut cur_lines, DiffLineKind::Header, line);
847 } else if line.starts_with("deleted file mode") {
848 cur_status = "deleted";
849 push_line(&mut cur_lines, DiffLineKind::Header, line);
850 } else if line.starts_with("rename from") || line.starts_with("rename to") {
851 cur_status = "renamed";
852 push_line(&mut cur_lines, DiffLineKind::Header, line);
853 } else if line.starts_with("similarity index")
854 || line.starts_with("copy from")
855 || line.starts_with("copy to")
856 || line.starts_with("index ")
857 || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("Binary files")
860 || line.starts_with('\\')
861 {
862 push_line(&mut cur_lines, DiffLineKind::Header, line);
863 } else if line.starts_with("@@") {
864 push_line(&mut cur_lines, DiffLineKind::Hunk, line);
865 } else if line.starts_with('+') {
866 push_line(&mut cur_lines, DiffLineKind::Added, line);
867 } else if line.starts_with('-') {
868 push_line(&mut cur_lines, DiffLineKind::Removed, line);
869 } else {
870 push_line(&mut cur_lines, DiffLineKind::Context, line);
871 }
872 }
873
874 if !cur_path.is_empty() {
875 files.push(ChangedFile {
876 path: cur_path,
877 status: cur_status.to_string(),
878 diff_lines: cur_lines,
879 });
880 }
881
882 files
883}
884
885async fn export_bare_clone(
894 source: &Path,
895 dest: &Path,
896 default_branch: &str,
897 include: &GlobSet,
898 exclude: &GlobSet,
899) -> Result<()> {
900 use tokio::process::Command;
901
902 if dest.exists() {
903 tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
904 }
905
906 let status = Command::new("git")
907 .arg("clone")
908 .arg("--bare")
909 .arg(source)
910 .arg(dest)
911 .stdout(std::process::Stdio::null())
912 .stderr(std::process::Stdio::null())
913 .status()
914 .await
915 .into_diagnostic()?;
916
917 if !status.success() {
918 return Err(miette::miette!(
919 "git clone --bare failed with exit status {status}"
920 ));
921 }
922
923 prune_excluded_refs(dest, default_branch, include, exclude).await?;
924
925 let status = Command::new("git")
927 .arg("-C")
928 .arg(dest)
929 .arg("update-server-info")
930 .stdout(std::process::Stdio::null())
931 .stderr(std::process::Stdio::null())
932 .status()
933 .await
934 .into_diagnostic()?;
935
936 if !status.success() {
937 warn!("git update-server-info failed; dumb HTTP cloning may not work");
938 }
939
940 Ok(())
941}
942
943async fn prune_excluded_refs(
951 dest: &Path,
952 default_branch: &str,
953 include: &GlobSet,
954 exclude: &GlobSet,
955) -> Result<()> {
956 use tokio::process::Command;
957
958 if include.is_empty() && exclude.is_empty() {
959 return Ok(());
960 }
961
962 let output = Command::new("git")
963 .arg("-C")
964 .arg(dest)
965 .arg("for-each-ref")
966 .arg("--format=%(refname)")
967 .arg("refs/heads")
968 .arg("refs/tags")
969 .output()
970 .await
971 .into_diagnostic()?;
972
973 if !output.status.success() {
974 warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
975 return Ok(());
976 }
977
978 let refnames = String::from_utf8_lossy(&output.stdout);
979 let mut removed_any = false;
980 let mut kept_branches: Vec<String> = Vec::new();
981
982 for refname in refnames.lines() {
983 let short_name = match refname
984 .strip_prefix("refs/heads/")
985 .or_else(|| refname.strip_prefix("refs/tags/"))
986 {
987 Some(s) => s,
988 None => continue,
989 };
990
991 if ref_is_included(short_name, include, exclude) {
992 if refname.starts_with("refs/heads/") {
993 kept_branches.push(short_name.to_string());
994 }
995 continue;
996 }
997
998 let status = Command::new("git")
999 .arg("-C")
1000 .arg(dest)
1001 .arg("update-ref")
1002 .arg("-d")
1003 .arg(refname)
1004 .stdout(std::process::Stdio::null())
1005 .stderr(std::process::Stdio::null())
1006 .status()
1007 .await
1008 .into_diagnostic()?;
1009
1010 if status.success() {
1011 removed_any = true;
1012 } else {
1013 warn!("failed to delete excluded ref '{refname}' from repository.git");
1014 }
1015 }
1016
1017 if !removed_any {
1018 return Ok(());
1019 }
1020
1021 let new_head = if kept_branches.iter().any(|b| b == default_branch) {
1026 Some(default_branch.to_string())
1027 } else if let Some(first) = kept_branches.first() {
1028 warn!(
1029 "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
1030 default_branch, first
1031 );
1032 Some(first.clone())
1033 } else {
1034 warn!(
1035 "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
1036 );
1037 None
1038 };
1039
1040 if let Some(branch) = new_head {
1041 let status = Command::new("git")
1042 .arg("-C")
1043 .arg(dest)
1044 .arg("symbolic-ref")
1045 .arg("HEAD")
1046 .arg(format!("refs/heads/{branch}"))
1047 .stdout(std::process::Stdio::null())
1048 .stderr(std::process::Stdio::null())
1049 .status()
1050 .await
1051 .into_diagnostic()?;
1052
1053 if !status.success() {
1054 warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
1055 }
1056 }
1057
1058 let status = Command::new("git")
1061 .arg("-C")
1062 .arg(dest)
1063 .arg("gc")
1064 .arg("--prune=now")
1065 .arg("--quiet")
1066 .status()
1067 .await
1068 .into_diagnostic()?;
1069
1070 if !status.success() {
1071 warn!(
1072 "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
1073 );
1074 }
1075
1076 Ok(())
1077}
1078
1079fn parse_message(raw: &str) -> (String, Option<String>) {
1083 if let Some(idx) = raw.find("\n\n") {
1084 let subject = raw[..idx].trim().to_string();
1085 let body = raw[idx + 2..].trim().to_string();
1086 let body = if body.is_empty() { None } else { Some(body) };
1087 (subject, body)
1088 } else {
1089 (raw.trim().to_string(), None)
1090 }
1091}