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