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 prefix = &git_cfg.prefix;
210
211 if prefix == "repository" {
212 warn!(
213 "The default git UI prefix has changed from 'repository' to '' (site root). \
214 Set prefix = \"repository\" in your [git_ui] config to keep the legacy layout."
215 );
216 }
217
218 let prefix_empty = prefix.is_empty();
219 let ui_dir = if prefix_empty {
220 output_dir.clone()
221 } else {
222 output_dir.join(prefix)
223 };
224 let bare_dir = output_dir.join("repository.git");
225
226 let root_path_for = |extra_depth: usize| -> String {
227 if prefix_empty {
228 "../".repeat(extra_depth)
229 } else {
230 "../".repeat(extra_depth + 1)
231 }
232 };
233
234 let git_ui_path = if prefix_empty {
235 String::new()
236 } else {
237 format!("{prefix}/")
238 };
239
240 tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?;
241 tokio::fs::create_dir_all(ui_dir.join("commit"))
242 .await
243 .into_diagnostic()?;
244
245 let repo_path: PathBuf = git_cfg
246 .repo_path
247 .clone()
248 .unwrap_or_else(|| PathBuf::from("."));
249
250 let max_commits = git_cfg.max_commits;
251 let default_branch = git_cfg.default_branch.clone();
252 let include = build_globset(&git_cfg.include)?;
253 let exclude = build_globset(&git_cfg.exclude)?;
254 let repo_path_clone = repo_path.clone();
255
256 let bare_default_branch = default_branch.clone();
260 let bare_include = include.clone();
261 let bare_exclude = exclude.clone();
262
263 let clone_url = generate_clone_command(config, git_cfg);
266
267 let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) =
275 tokio::task::spawn_blocking(move || -> Result<_> {
276 let repo = match gix::discover(&repo_path_clone) {
277 Ok(r) => r,
278 Err(e) => {
279 warn!(
280 "git_ui: could not open repository at {}: {e}",
281 repo_path_clone.display()
282 );
283 return Ok((vec![], vec![], vec![], vec![], vec![]));
284 }
285 };
286
287 let branches = collect_branch_entries(&repo, &default_branch, &include, &exclude)?;
288 let (tags, ref_branches) = collect_refs(&repo, &include, &exclude)?;
289 let ref_labels = build_ref_labels(&repo, &include, &exclude)?;
290
291 let mut unique_map: HashMap<String, CommitInfo> = HashMap::new();
293 let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new();
294
295 for branch in &branches {
296 let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?;
297 for c in &commits {
298 unique_map
299 .entry(c.hash.clone())
300 .or_insert_with(|| c.clone());
301 }
302 branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits));
303 }
304
305 let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect();
306
307 let mut seen: HashMap<String, gix::ObjectId> = HashMap::new();
309 for branch in &branches {
310 seen.insert(branch.tip.to_string(), branch.tip);
311 }
312 let refs_platform = repo.references().into_diagnostic()?;
313 for reference in refs_platform.all().into_diagnostic()? {
314 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
315 let name = reference.name().as_bstr().to_str_lossy().into_owned();
316 if !name.starts_with("refs/tags/") {
317 continue;
318 }
319 let short_name = name.trim_start_matches("refs/tags/");
320 if !ref_is_included(short_name, &include, &exclude) {
321 continue;
322 }
323 if let Ok(id) = reference.peel_to_id() {
324 let hash = id.to_string();
325 seen.entry(hash).or_insert_with(|| id.detach());
326 }
327 }
328 let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect();
329
330 Ok((
331 branch_pages,
332 unique_commits,
333 tags,
334 ref_branches,
335 browse_revisions,
336 ))
337 })
338 .await
339 .into_diagnostic()??;
340
341 if branch_pages.is_empty() {
342 return Ok(());
344 }
345
346 let progress = Progress::new("git ui");
347
348 progress.set_message("cloning bare repository…");
350 if let Err(e) = export_bare_clone(
351 &repo_path,
352 &bare_dir,
353 &bare_default_branch,
354 &bare_include,
355 &bare_exclude,
356 )
357 .await
358 {
359 progress.finish_failed(&e);
360 return Err(e);
361 }
362
363 let mut tera = Tera::default();
365 let theme_path = PathBuf::from(".abbaye").join("theme");
366
367 crate::site::register_format_templates(
368 &mut tera,
369 &theme_path,
370 &config.site.formats,
371 &[
372 ("git_log", TEMPLATE_GIT_LOG_HTML, TEMPLATE_GIT_LOG_GEMTEXT),
373 (
374 "git_commit",
375 TEMPLATE_GIT_COMMIT_HTML,
376 TEMPLATE_GIT_COMMIT_GEMTEXT,
377 ),
378 (
379 "git_refs",
380 TEMPLATE_GIT_REFS_HTML,
381 TEMPLATE_GIT_REFS_GEMTEXT,
382 ),
383 ],
384 )?;
385
386 let mut nav_entries: Vec<(String, String)> = branch_pages
390 .iter()
391 .map(|(name, file, _)| (name.clone(), file.clone()))
392 .collect();
393 nav_entries.sort_by(|(na, fa), (nb, fb)| {
394 let a_default = fa == "index.html";
395 let b_default = fb == "index.html";
396 b_default.cmp(&a_default).then(na.cmp(nb))
397 });
398
399 progress.set_message("rendering log pages…");
401 for (short_name, filename, commits) in &branch_pages {
402 let truncated = commits.len() >= max_commits;
403
404 let branch_nav: Vec<BranchNav> = nav_entries
405 .iter()
406 .map(|(bn, bf)| BranchNav {
407 short_name: bn.clone(),
408 filename: bf.clone(),
409 is_current: bn == short_name,
410 })
411 .collect();
412
413 for format in &config.site.formats {
414 let suffix = format.extension();
415 let tmpl_name = format!("git_log.{suffix}");
416 let ext = format.extension();
417 let out_filename = filename.replace(".html", &format!(".{ext}"));
418
419 let mut ctx = Context::new();
420 ctx.insert("project_name", &config.site.name);
421 ctx.insert("lang", &config.site.lang);
422 ctx.insert("clone_url", &clone_url);
423 ctx.insert("current_branch", short_name);
424 ctx.insert("branch_nav", &branch_nav);
425 ctx.insert("commits", commits);
426 ctx.insert("truncated", &truncated);
427 ctx.insert("root_path", &root_path_for(0));
428 ctx.insert("git_ui_path", &git_ui_path);
429
430 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
431 tokio::fs::write(ui_dir.join(&out_filename), content)
432 .await
433 .into_diagnostic()?;
434 }
435 }
436
437 for format in &config.site.formats {
439 let suffix = format.extension();
440 let tmpl_name = format!("git_refs.{suffix}");
441 let ext = format.extension();
442 let out_filename = format!("refs.{ext}");
443
444 let mut ctx = Context::new();
445 ctx.insert("project_name", &config.site.name);
446 ctx.insert("lang", &config.site.lang);
447 ctx.insert("clone_url", &clone_url);
448 ctx.insert("tags", &tags);
449 ctx.insert("branches", &ref_branches);
450 ctx.insert("root_path", &root_path_for(0));
451 ctx.insert("git_ui_path", &git_ui_path);
452
453 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
454 tokio::fs::write(ui_dir.join(&out_filename), content)
455 .await
456 .into_diagnostic()?;
457 }
458
459 progress.set_message(format!("rendering {} commit pages…", unique_commits.len()));
461 for commit_info in &unique_commits {
462 let changed_files = get_changed_files(&commit_info.hash).await?;
463 let has_browse = !commit_info.ref_badges.is_empty();
464 let commit_dir = ui_dir.join("commit");
465
466 for format in &config.site.formats {
467 let suffix = format.extension();
468 let tmpl_name = format!("git_commit.{suffix}");
469 let ext = format.extension();
470 let out_filename = format!("{}.{ext}", commit_info.hash);
471
472 let mut ctx = Context::new();
473 ctx.insert("project_name", &config.site.name);
474 ctx.insert("lang", &config.site.lang);
475 ctx.insert("clone_url", &clone_url);
476 ctx.insert("commit", commit_info);
477 ctx.insert("changed_files", &changed_files);
478 ctx.insert("has_browse", &has_browse);
479 ctx.insert("root_path", &root_path_for(1));
480 ctx.insert("git_ui_path", &git_ui_path);
481
482 let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?;
483 tokio::fs::write(commit_dir.join(&out_filename), content)
484 .await
485 .into_diagnostic()?;
486 }
487 }
488
489 if !browse_revisions.is_empty() {
491 progress.set_message(format!(
492 "building browse pages for {} revision(s)…",
493 browse_revisions.len()
494 ));
495 let browse_dir = ui_dir.join("browse");
496 tokio::fs::create_dir_all(&browse_dir)
497 .await
498 .into_diagnostic()?;
499
500 let project_name = config.site.name.clone();
501 let lang = config.site.lang.clone();
502 let clone_url_browse = clone_url.clone();
503 let theme_path = PathBuf::from(".abbaye").join("theme");
504 let repo_path_browse = repo_path.clone();
505 let formats = config.site.formats.clone();
506 let git_ui_path_browse = git_ui_path.clone();
507 let prefix_empty_browse = prefix_empty;
508
509 tokio::task::spawn_blocking(move || {
510 crate::git_browse::build_browse_pages(
511 &browse_revisions,
512 &browse_dir,
513 &repo_path_browse,
514 &theme_path,
515 &project_name,
516 &lang,
517 &clone_url_browse,
518 &formats,
519 &git_ui_path_browse,
520 prefix_empty_browse,
521 )
522 })
523 .await
524 .into_diagnostic()??
525 }
526
527 progress.finish_done();
528 Ok(())
529}
530
531pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
532 let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
533 config.site.base_url.as_ref().map(|base| {
534 format!(
535 "{}/repository.git {}",
536 base.trim_end_matches('/'),
537 if config.site.name.contains(" ") {
538 format!("'{}'", config.site.name)
539 } else {
540 config.site.name.clone()
541 }
542 )
543 })
544 });
545 clone_url
546}
547
548fn build_globset(patterns: &[String]) -> Result<GlobSet> {
554 let mut builder = GlobSetBuilder::new();
555 for pattern in patterns {
556 let glob = Glob::new(pattern)
557 .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
558 builder.add(glob);
559 }
560 builder.build().into_diagnostic()
561}
562
563fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
571 let included = include.is_empty() || include.is_match(short_name);
572 included && !exclude.is_match(short_name)
573}
574
575fn collect_branch_entries(
587 repo: &gix::Repository,
588 default_branch: &str,
589 include: &GlobSet,
590 exclude: &GlobSet,
591) -> Result<Vec<BranchEntry>> {
592 let mut entries: Vec<BranchEntry> = Vec::new();
593
594 let refs_platform = repo.references().into_diagnostic()?;
595 for reference in refs_platform.all().into_diagnostic()? {
596 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
597 let name = reference.name().as_bstr().to_str_lossy().into_owned();
598
599 if !name.starts_with("refs/heads/") {
600 continue;
601 }
602
603 let short_name = name.trim_start_matches("refs/heads/").to_string();
604 if !ref_is_included(&short_name, include, exclude) {
605 continue;
606 }
607 let tip = match reference.peel_to_id() {
608 Ok(id) => id.detach(),
609 Err(_) => continue,
610 };
611
612 let filename = format!("{}.html", short_name.replace('/', "-"));
614 entries.push(BranchEntry {
615 short_name,
616 filename,
617 tip,
618 });
619 }
620
621 entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
623
624 if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
626 e.filename = "index.html".to_string();
627 } else if let Some(first) = entries.first_mut() {
628 warn!(
629 "git_ui: default branch '{}' not found; using '{}' as index.html",
630 default_branch, first.short_name
631 );
632 first.filename = "index.html".to_string();
633 }
634
635 Ok(entries)
636}
637
638fn build_ref_labels(
644 repo: &gix::Repository,
645 include: &GlobSet,
646 exclude: &GlobSet,
647) -> Result<HashMap<String, Vec<RefBadge>>> {
648 let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
649
650 let refs_platform = repo.references().into_diagnostic()?;
651 for reference in refs_platform.all().into_diagnostic()? {
652 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
653 let name = reference.name().as_bstr().to_str_lossy().into_owned();
654
655 if name == "HEAD" || name.starts_with("refs/remotes/") {
656 continue;
657 }
658
659 let hash = match reference.peel_to_id() {
660 Ok(id) => id.to_string(),
661 Err(_) => continue,
662 };
663
664 let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
665 if !ref_is_included(label, include, exclude) {
666 continue;
667 }
668 RefBadge {
669 label: label.to_string(),
670 kind: RefBadgeKind::Tag,
671 }
672 } else if let Some(label) = name.strip_prefix("refs/heads/") {
673 if !ref_is_included(label, include, exclude) {
674 continue;
675 }
676 RefBadge {
677 label: label.to_string(),
678 kind: RefBadgeKind::Branch,
679 }
680 } else {
681 continue;
682 };
683
684 map.entry(hash).or_default().push(badge);
685 }
686
687 for badges in map.values_mut() {
689 badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
690 }
691
692 Ok(map)
693}
694
695fn collect_commits(
697 repo: &gix::Repository,
698 tip: gix::ObjectId,
699 max: usize,
700 ref_labels: &HashMap<String, Vec<RefBadge>>,
701) -> Result<Vec<CommitInfo>> {
702 let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
703 let mut commits = Vec::new();
704
705 for info in walk.take(max) {
706 let info = info.into_diagnostic()?;
707 let id = info.id;
708
709 let object = repo.find_object(id).into_diagnostic()?;
710 let commit = object.into_commit();
711 let decoded = commit.decode().into_diagnostic()?;
712
713 let author = decoded.author().into_diagnostic()?;
714 let author_name = author.name.to_str_lossy().into_owned();
715 let author_email = author.email.to_str_lossy().into_owned();
716 let unix_secs: i64 = author
717 .time
718 .split_whitespace()
719 .next()
720 .and_then(|s| s.parse().ok())
721 .unwrap_or(0);
722
723 let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
724 let date = dt.format("%Y-%m-%d").to_string();
725 let date_iso = dt.to_rfc3339();
726 let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
727
728 let raw_msg = decoded.message.to_str_lossy();
729 let (subject, body) = parse_message(&raw_msg);
730
731 let hash = id.to_string();
732 let hash_short = hash[..7].to_string();
733
734 let parents = info
735 .parent_ids
736 .iter()
737 .map(|p| {
738 let h = p.to_string();
739 let hs = h[..7].to_string();
740 CommitParent {
741 hash: h,
742 hash_short: hs,
743 }
744 })
745 .collect();
746
747 let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
748
749 commits.push(CommitInfo {
750 hash,
751 hash_short,
752 author_name,
753 author_email,
754 date,
755 date_iso,
756 datetime_display,
757 subject,
758 body,
759 parents,
760 ref_badges,
761 });
762 }
763
764 Ok(commits)
765}
766
767fn collect_refs(
772 repo: &gix::Repository,
773 include: &GlobSet,
774 exclude: &GlobSet,
775) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
776 let mut tags: Vec<RefInfo> = Vec::new();
777 let mut branches: Vec<RefInfo> = Vec::new();
778
779 let refs_platform = repo.references().into_diagnostic()?;
780 for reference in refs_platform.all().into_diagnostic()? {
781 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
782 let name = reference.name().as_bstr().to_str_lossy().into_owned();
783
784 if name.starts_with("refs/remotes/") || name == "HEAD" {
785 continue;
786 }
787
788 let hash = match reference.peel_to_id() {
789 Ok(id) => id.to_string(),
790 Err(_) => continue,
791 };
792 let hash_short = hash[..7.min(hash.len())].to_string();
793
794 if name.starts_with("refs/tags/") {
795 let short_name = name.trim_start_matches("refs/tags/").to_string();
796 if !ref_is_included(&short_name, include, exclude) {
797 continue;
798 }
799 tags.push(RefInfo {
800 name,
801 short_name,
802 hash,
803 hash_short,
804 });
805 } else if name.starts_with("refs/heads/") {
806 let short_name = name.trim_start_matches("refs/heads/").to_string();
807 if !ref_is_included(&short_name, include, exclude) {
808 continue;
809 }
810 branches.push(RefInfo {
811 name,
812 short_name,
813 hash,
814 hash_short,
815 });
816 }
817 }
818
819 tags.sort_by(|a, b| b.name.cmp(&a.name));
821 branches.sort_by(|a, b| a.name.cmp(&b.name));
822
823 Ok((tags, branches))
824}
825
826async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
829 let output = tokio::process::Command::new("git")
830 .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
831 .output()
832 .await
833 .into_diagnostic()?;
834
835 if !output.status.success() {
836 return Ok(vec![]);
837 }
838
839 Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
840}
841
842fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
850 let mut files: Vec<ChangedFile> = Vec::new();
851 let mut cur_lines: Vec<DiffLine> = Vec::new();
852 let mut cur_path = String::new();
853 let mut cur_status = "modified";
854
855 let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
856 lines.push(DiffLine {
857 kind,
858 content: content.to_string(),
859 });
860 };
861
862 for line in text.lines() {
863 if line.starts_with("diff --git ") {
864 if !cur_path.is_empty() {
865 files.push(ChangedFile {
866 path: cur_path.clone(),
867 status: cur_status.to_string(),
868 diff_lines: std::mem::take(&mut cur_lines),
869 });
870 }
871 cur_path = line
874 .rsplit_once(" b/")
875 .map(|(_, p)| p.to_string())
876 .unwrap_or_default();
877 cur_status = "modified";
878 push_line(&mut cur_lines, DiffLineKind::Header, line);
879 } else if line.starts_with("new file mode") {
880 cur_status = "added";
881 push_line(&mut cur_lines, DiffLineKind::Header, line);
882 } else if line.starts_with("deleted file mode") {
883 cur_status = "deleted";
884 push_line(&mut cur_lines, DiffLineKind::Header, line);
885 } else if line.starts_with("rename from") || line.starts_with("rename to") {
886 cur_status = "renamed";
887 push_line(&mut cur_lines, DiffLineKind::Header, line);
888 } else if line.starts_with("similarity index")
889 || line.starts_with("copy from")
890 || line.starts_with("copy to")
891 || line.starts_with("index ")
892 || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("Binary files")
895 || line.starts_with('\\')
896 {
897 push_line(&mut cur_lines, DiffLineKind::Header, line);
898 } else if line.starts_with("@@") {
899 push_line(&mut cur_lines, DiffLineKind::Hunk, line);
900 } else if line.starts_with('+') {
901 push_line(&mut cur_lines, DiffLineKind::Added, line);
902 } else if line.starts_with('-') {
903 push_line(&mut cur_lines, DiffLineKind::Removed, line);
904 } else {
905 push_line(&mut cur_lines, DiffLineKind::Context, line);
906 }
907 }
908
909 if !cur_path.is_empty() {
910 files.push(ChangedFile {
911 path: cur_path,
912 status: cur_status.to_string(),
913 diff_lines: cur_lines,
914 });
915 }
916
917 files
918}
919
920async fn export_bare_clone(
929 source: &Path,
930 dest: &Path,
931 default_branch: &str,
932 include: &GlobSet,
933 exclude: &GlobSet,
934) -> Result<()> {
935 use tokio::process::Command;
936
937 if dest.exists() {
938 tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
939 }
940
941 let status = Command::new("git")
942 .arg("clone")
943 .arg("--bare")
944 .arg(source)
945 .arg(dest)
946 .stdout(std::process::Stdio::null())
947 .stderr(std::process::Stdio::null())
948 .status()
949 .await
950 .into_diagnostic()?;
951
952 if !status.success() {
953 return Err(miette::miette!(
954 "git clone --bare failed with exit status {status}"
955 ));
956 }
957
958 prune_excluded_refs(dest, default_branch, include, exclude).await?;
959
960 let status = Command::new("git")
962 .arg("-C")
963 .arg(dest)
964 .arg("update-server-info")
965 .stdout(std::process::Stdio::null())
966 .stderr(std::process::Stdio::null())
967 .status()
968 .await
969 .into_diagnostic()?;
970
971 if !status.success() {
972 warn!("git update-server-info failed; dumb HTTP cloning may not work");
973 }
974
975 Ok(())
976}
977
978async fn prune_excluded_refs(
986 dest: &Path,
987 default_branch: &str,
988 include: &GlobSet,
989 exclude: &GlobSet,
990) -> Result<()> {
991 use tokio::process::Command;
992
993 if include.is_empty() && exclude.is_empty() {
994 return Ok(());
995 }
996
997 let output = Command::new("git")
998 .arg("-C")
999 .arg(dest)
1000 .arg("for-each-ref")
1001 .arg("--format=%(refname)")
1002 .arg("refs/heads")
1003 .arg("refs/tags")
1004 .output()
1005 .await
1006 .into_diagnostic()?;
1007
1008 if !output.status.success() {
1009 warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
1010 return Ok(());
1011 }
1012
1013 let refnames = String::from_utf8_lossy(&output.stdout);
1014 let mut removed_any = false;
1015 let mut kept_branches: Vec<String> = Vec::new();
1016
1017 for refname in refnames.lines() {
1018 let short_name = match refname
1019 .strip_prefix("refs/heads/")
1020 .or_else(|| refname.strip_prefix("refs/tags/"))
1021 {
1022 Some(s) => s,
1023 None => continue,
1024 };
1025
1026 if ref_is_included(short_name, include, exclude) {
1027 if refname.starts_with("refs/heads/") {
1028 kept_branches.push(short_name.to_string());
1029 }
1030 continue;
1031 }
1032
1033 let status = Command::new("git")
1034 .arg("-C")
1035 .arg(dest)
1036 .arg("update-ref")
1037 .arg("-d")
1038 .arg(refname)
1039 .stdout(std::process::Stdio::null())
1040 .stderr(std::process::Stdio::null())
1041 .status()
1042 .await
1043 .into_diagnostic()?;
1044
1045 if status.success() {
1046 removed_any = true;
1047 } else {
1048 warn!("failed to delete excluded ref '{refname}' from repository.git");
1049 }
1050 }
1051
1052 if !removed_any {
1053 return Ok(());
1054 }
1055
1056 let new_head = if kept_branches.iter().any(|b| b == default_branch) {
1061 Some(default_branch.to_string())
1062 } else if let Some(first) = kept_branches.first() {
1063 warn!(
1064 "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
1065 default_branch, first
1066 );
1067 Some(first.clone())
1068 } else {
1069 warn!(
1070 "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
1071 );
1072 None
1073 };
1074
1075 if let Some(branch) = new_head {
1076 let status = Command::new("git")
1077 .arg("-C")
1078 .arg(dest)
1079 .arg("symbolic-ref")
1080 .arg("HEAD")
1081 .arg(format!("refs/heads/{branch}"))
1082 .stdout(std::process::Stdio::null())
1083 .stderr(std::process::Stdio::null())
1084 .status()
1085 .await
1086 .into_diagnostic()?;
1087
1088 if !status.success() {
1089 warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
1090 }
1091 }
1092
1093 let status = Command::new("git")
1096 .arg("-C")
1097 .arg(dest)
1098 .arg("gc")
1099 .arg("--prune=now")
1100 .arg("--quiet")
1101 .status()
1102 .await
1103 .into_diagnostic()?;
1104
1105 if !status.success() {
1106 warn!(
1107 "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
1108 );
1109 }
1110
1111 Ok(())
1112}
1113
1114fn parse_message(raw: &str) -> (String, Option<String>) {
1118 if let Some(idx) = raw.find("\n\n") {
1119 let subject = raw[..idx].trim().to_string();
1120 let body = raw[idx + 2..].trim().to_string();
1121 let body = if body.is_empty() { None } else { Some(body) };
1122 (subject, body)
1123 } else {
1124 (raw.trim().to_string(), None)
1125 }
1126}