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_command(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_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> {
546 let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| {
547 config.site.base_url.as_ref().map(|base| {
548 format!(
549 "{}/repository.git {}",
550 base.trim_end_matches('/'),
551 if config.site.name.contains(" ") {
552 format!("'{}'", config.site.name)
553 } else {
554 config.site.name.clone()
555 }
556 )
557 })
558 });
559 clone_url
560}
561
562fn build_globset(patterns: &[String]) -> Result<GlobSet> {
568 let mut builder = GlobSetBuilder::new();
569 for pattern in patterns {
570 let glob = Glob::new(pattern)
571 .map_err(|e| miette::miette!("git_ui: invalid glob pattern '{pattern}': {e}"))?;
572 builder.add(glob);
573 }
574 builder.build().into_diagnostic()
575}
576
577fn ref_is_included(short_name: &str, include: &GlobSet, exclude: &GlobSet) -> bool {
585 let included = include.is_empty() || include.is_match(short_name);
586 included && !exclude.is_match(short_name)
587}
588
589fn collect_branch_entries(
601 repo: &gix::Repository,
602 default_branch: &str,
603 include: &GlobSet,
604 exclude: &GlobSet,
605) -> Result<Vec<BranchEntry>> {
606 let mut entries: Vec<BranchEntry> = Vec::new();
607
608 let refs_platform = repo.references().into_diagnostic()?;
609 for reference in refs_platform.all().into_diagnostic()? {
610 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
611 let name = reference.name().as_bstr().to_str_lossy().into_owned();
612
613 if !name.starts_with("refs/heads/") {
614 continue;
615 }
616
617 let short_name = name.trim_start_matches("refs/heads/").to_string();
618 if !ref_is_included(&short_name, include, exclude) {
619 continue;
620 }
621 let tip = match reference.peel_to_id() {
622 Ok(id) => id.detach(),
623 Err(_) => continue,
624 };
625
626 let filename = format!("{}.html", short_name.replace('/', "-"));
628 entries.push(BranchEntry {
629 short_name,
630 filename,
631 tip,
632 });
633 }
634
635 entries.sort_by(|a, b| a.short_name.cmp(&b.short_name));
637
638 if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) {
640 e.filename = "index.html".to_string();
641 } else if let Some(first) = entries.first_mut() {
642 warn!(
643 "git_ui: default branch '{}' not found; using '{}' as index.html",
644 default_branch, first.short_name
645 );
646 first.filename = "index.html".to_string();
647 }
648
649 Ok(entries)
650}
651
652fn build_ref_labels(
658 repo: &gix::Repository,
659 include: &GlobSet,
660 exclude: &GlobSet,
661) -> Result<HashMap<String, Vec<RefBadge>>> {
662 let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new();
663
664 let refs_platform = repo.references().into_diagnostic()?;
665 for reference in refs_platform.all().into_diagnostic()? {
666 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
667 let name = reference.name().as_bstr().to_str_lossy().into_owned();
668
669 if name == "HEAD" || name.starts_with("refs/remotes/") {
670 continue;
671 }
672
673 let hash = match reference.peel_to_id() {
674 Ok(id) => id.to_string(),
675 Err(_) => continue,
676 };
677
678 let badge = if let Some(label) = name.strip_prefix("refs/tags/") {
679 if !ref_is_included(label, include, exclude) {
680 continue;
681 }
682 RefBadge {
683 label: label.to_string(),
684 kind: RefBadgeKind::Tag,
685 url: Some(format!("browse/{}/", label)),
686 }
687 } else if let Some(label) = name.strip_prefix("refs/heads/") {
688 if !ref_is_included(label, include, exclude) {
689 continue;
690 }
691 RefBadge {
692 label: label.to_string(),
693 kind: RefBadgeKind::Branch,
694 url: Some(format!("browse/{}/", label)),
695 }
696 } else {
697 continue;
698 };
699
700 map.entry(hash).or_default().push(badge);
701 }
702
703 for badges in map.values_mut() {
705 badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label)));
706 }
707
708 Ok(map)
709}
710
711fn collect_commits(
713 repo: &gix::Repository,
714 tip: gix::ObjectId,
715 max: usize,
716 ref_labels: &HashMap<String, Vec<RefBadge>>,
717) -> Result<Vec<CommitInfo>> {
718 let walk = repo.rev_walk([tip]).all().into_diagnostic()?;
719 let mut commits = Vec::new();
720
721 for info in walk.take(max) {
722 let info = info.into_diagnostic()?;
723 let id = info.id;
724
725 let object = repo.find_object(id).into_diagnostic()?;
726 let commit = object.into_commit();
727 let decoded = commit.decode().into_diagnostic()?;
728
729 let author = decoded.author().into_diagnostic()?;
730 let author_name = author.name.to_str_lossy().into_owned();
731 let author_email = author.email.to_str_lossy().into_owned();
732 let unix_secs: i64 = author
733 .time
734 .split_whitespace()
735 .next()
736 .and_then(|s| s.parse().ok())
737 .unwrap_or(0);
738
739 let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default();
740 let date = dt.format("%Y-%m-%d").to_string();
741 let date_iso = dt.to_rfc3339();
742 let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string();
743
744 let raw_msg = decoded.message.to_str_lossy();
745 let (subject, body) = parse_message(&raw_msg);
746
747 let hash = id.to_string();
748 let hash_short = hash[..7].to_string();
749
750 let parents = info
751 .parent_ids
752 .iter()
753 .map(|p| {
754 let h = p.to_string();
755 let hs = h[..7].to_string();
756 CommitParent {
757 hash: h,
758 hash_short: hs,
759 }
760 })
761 .collect();
762
763 let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default();
764
765 commits.push(CommitInfo {
766 hash,
767 hash_short,
768 author_name,
769 author_email,
770 date,
771 date_iso,
772 datetime_display,
773 subject,
774 body,
775 parents,
776 ref_badges,
777 });
778 }
779
780 Ok(commits)
781}
782
783fn collect_refs(
788 repo: &gix::Repository,
789 include: &GlobSet,
790 exclude: &GlobSet,
791) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> {
792 let mut tags: Vec<RefInfo> = Vec::new();
793 let mut branches: Vec<RefInfo> = Vec::new();
794
795 let refs_platform = repo.references().into_diagnostic()?;
796 for reference in refs_platform.all().into_diagnostic()? {
797 let mut reference = reference.map_err(|e| miette::miette!("{e}"))?;
798 let name = reference.name().as_bstr().to_str_lossy().into_owned();
799
800 if name.starts_with("refs/remotes/") || name == "HEAD" {
801 continue;
802 }
803
804 let hash = match reference.peel_to_id() {
805 Ok(id) => id.to_string(),
806 Err(_) => continue,
807 };
808 let hash_short = hash[..7.min(hash.len())].to_string();
809
810 if name.starts_with("refs/tags/") {
811 let short_name = name.trim_start_matches("refs/tags/").to_string();
812 if !ref_is_included(&short_name, include, exclude) {
813 continue;
814 }
815 tags.push(RefInfo {
816 name,
817 short_name,
818 hash,
819 hash_short,
820 });
821 } else if name.starts_with("refs/heads/") {
822 let short_name = name.trim_start_matches("refs/heads/").to_string();
823 if !ref_is_included(&short_name, include, exclude) {
824 continue;
825 }
826 branches.push(RefInfo {
827 name,
828 short_name,
829 hash,
830 hash_short,
831 });
832 }
833 }
834
835 tags.sort_by(|a, b| b.name.cmp(&a.name));
837 branches.sort_by(|a, b| a.name.cmp(&b.name));
838
839 Ok((tags, branches))
840}
841
842async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> {
845 let output = tokio::process::Command::new("git")
846 .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash])
847 .output()
848 .await
849 .into_diagnostic()?;
850
851 if !output.status.success() {
852 return Ok(vec![]);
853 }
854
855 Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout)))
856}
857
858fn parse_diff_output(text: &str) -> Vec<ChangedFile> {
866 let mut files: Vec<ChangedFile> = Vec::new();
867 let mut cur_lines: Vec<DiffLine> = Vec::new();
868 let mut cur_path = String::new();
869 let mut cur_status = "modified";
870
871 let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| {
872 lines.push(DiffLine {
873 kind,
874 content: content.to_string(),
875 });
876 };
877
878 for line in text.lines() {
879 if line.starts_with("diff --git ") {
880 if !cur_path.is_empty() {
881 files.push(ChangedFile {
882 path: cur_path.clone(),
883 status: cur_status.to_string(),
884 diff_lines: std::mem::take(&mut cur_lines),
885 });
886 }
887 cur_path = line
890 .rsplit_once(" b/")
891 .map(|(_, p)| p.to_string())
892 .unwrap_or_default();
893 cur_status = "modified";
894 push_line(&mut cur_lines, DiffLineKind::Header, line);
895 } else if line.starts_with("new file mode") {
896 cur_status = "added";
897 push_line(&mut cur_lines, DiffLineKind::Header, line);
898 } else if line.starts_with("deleted file mode") {
899 cur_status = "deleted";
900 push_line(&mut cur_lines, DiffLineKind::Header, line);
901 } else if line.starts_with("rename from") || line.starts_with("rename to") {
902 cur_status = "renamed";
903 push_line(&mut cur_lines, DiffLineKind::Header, line);
904 } else if line.starts_with("similarity index")
905 || line.starts_with("copy from")
906 || line.starts_with("copy to")
907 || line.starts_with("index ")
908 || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("Binary files")
911 || line.starts_with('\\')
912 {
913 push_line(&mut cur_lines, DiffLineKind::Header, line);
914 } else if line.starts_with("@@") {
915 push_line(&mut cur_lines, DiffLineKind::Hunk, line);
916 } else if line.starts_with('+') {
917 push_line(&mut cur_lines, DiffLineKind::Added, line);
918 } else if line.starts_with('-') {
919 push_line(&mut cur_lines, DiffLineKind::Removed, line);
920 } else {
921 push_line(&mut cur_lines, DiffLineKind::Context, line);
922 }
923 }
924
925 if !cur_path.is_empty() {
926 files.push(ChangedFile {
927 path: cur_path,
928 status: cur_status.to_string(),
929 diff_lines: cur_lines,
930 });
931 }
932
933 files
934}
935
936async fn export_bare_clone(
945 source: &Path,
946 dest: &Path,
947 default_branch: &str,
948 include: &GlobSet,
949 exclude: &GlobSet,
950) -> Result<()> {
951 use tokio::process::Command;
952
953 if dest.exists() {
954 tokio::fs::remove_dir_all(dest).await.into_diagnostic()?;
955 }
956
957 let status = Command::new("git")
958 .arg("clone")
959 .arg("--bare")
960 .arg(source)
961 .arg(dest)
962 .stdout(std::process::Stdio::null())
963 .stderr(std::process::Stdio::null())
964 .status()
965 .await
966 .into_diagnostic()?;
967
968 if !status.success() {
969 return Err(miette::miette!(
970 "git clone --bare failed with exit status {status}"
971 ));
972 }
973
974 prune_excluded_refs(dest, default_branch, include, exclude).await?;
975
976 let status = Command::new("git")
978 .arg("-C")
979 .arg(dest)
980 .arg("update-server-info")
981 .stdout(std::process::Stdio::null())
982 .stderr(std::process::Stdio::null())
983 .status()
984 .await
985 .into_diagnostic()?;
986
987 if !status.success() {
988 warn!("git update-server-info failed; dumb HTTP cloning may not work");
989 }
990
991 Ok(())
992}
993
994async fn prune_excluded_refs(
1002 dest: &Path,
1003 default_branch: &str,
1004 include: &GlobSet,
1005 exclude: &GlobSet,
1006) -> Result<()> {
1007 use tokio::process::Command;
1008
1009 if include.is_empty() && exclude.is_empty() {
1010 return Ok(());
1011 }
1012
1013 let output = Command::new("git")
1014 .arg("-C")
1015 .arg(dest)
1016 .arg("for-each-ref")
1017 .arg("--format=%(refname)")
1018 .arg("refs/heads")
1019 .arg("refs/tags")
1020 .output()
1021 .await
1022 .into_diagnostic()?;
1023
1024 if !output.status.success() {
1025 warn!("git for-each-ref failed; skipping include/exclude pruning of repository.git");
1026 return Ok(());
1027 }
1028
1029 let refnames = String::from_utf8_lossy(&output.stdout);
1030 let mut removed_any = false;
1031 let mut kept_branches: Vec<String> = Vec::new();
1032
1033 for refname in refnames.lines() {
1034 let short_name = match refname
1035 .strip_prefix("refs/heads/")
1036 .or_else(|| refname.strip_prefix("refs/tags/"))
1037 {
1038 Some(s) => s,
1039 None => continue,
1040 };
1041
1042 if ref_is_included(short_name, include, exclude) {
1043 if refname.starts_with("refs/heads/") {
1044 kept_branches.push(short_name.to_string());
1045 }
1046 continue;
1047 }
1048
1049 let status = Command::new("git")
1050 .arg("-C")
1051 .arg(dest)
1052 .arg("update-ref")
1053 .arg("-d")
1054 .arg(refname)
1055 .stdout(std::process::Stdio::null())
1056 .stderr(std::process::Stdio::null())
1057 .status()
1058 .await
1059 .into_diagnostic()?;
1060
1061 if status.success() {
1062 removed_any = true;
1063 } else {
1064 warn!("failed to delete excluded ref '{refname}' from repository.git");
1065 }
1066 }
1067
1068 if !removed_any {
1069 return Ok(());
1070 }
1071
1072 let new_head = if kept_branches.iter().any(|b| b == default_branch) {
1077 Some(default_branch.to_string())
1078 } else if let Some(first) = kept_branches.first() {
1079 warn!(
1080 "git_ui: default branch '{}' excluded from repository.git; using '{}' for HEAD instead",
1081 default_branch, first
1082 );
1083 Some(first.clone())
1084 } else {
1085 warn!(
1086 "git_ui: include/exclude filtered out every branch; repository.git will have no usable HEAD"
1087 );
1088 None
1089 };
1090
1091 if let Some(branch) = new_head {
1092 let status = Command::new("git")
1093 .arg("-C")
1094 .arg(dest)
1095 .arg("symbolic-ref")
1096 .arg("HEAD")
1097 .arg(format!("refs/heads/{branch}"))
1098 .stdout(std::process::Stdio::null())
1099 .stderr(std::process::Stdio::null())
1100 .status()
1101 .await
1102 .into_diagnostic()?;
1103
1104 if !status.success() {
1105 warn!("failed to repoint HEAD in repository.git after pruning excluded refs");
1106 }
1107 }
1108
1109 let status = Command::new("git")
1112 .arg("-C")
1113 .arg(dest)
1114 .arg("gc")
1115 .arg("--prune=now")
1116 .arg("--quiet")
1117 .status()
1118 .await
1119 .into_diagnostic()?;
1120
1121 if !status.success() {
1122 warn!(
1123 "git gc --prune=now failed on repository.git; excluded objects may still be present on disk"
1124 );
1125 }
1126
1127 Ok(())
1128}
1129
1130fn parse_message(raw: &str) -> (String, Option<String>) {
1134 if let Some(idx) = raw.find("\n\n") {
1135 let subject = raw[..idx].trim().to_string();
1136 let body = raw[idx + 2..].trim().to_string();
1137 let body = if body.is_empty() { None } else { Some(body) };
1138 (subject, body)
1139 } else {
1140 (raw.trim().to_string(), None)
1141 }
1142}