1use anyhow::{Context, Result, anyhow};
2use axum::response::IntoResponse;
3use bytes::{BufMut, Bytes, BytesMut, buf::Writer};
4use comrak::{ComrakPlugins, Options};
5use flate2::write::GzEncoder;
6use gix::{
7 ObjectId, ThreadSafeRepository,
8 actor::SignatureRef,
9 bstr::{BStr, BString, ByteSlice, ByteVec},
10 diff::blob::{Sink, platform::prepare_diff::Operation},
11 object::Kind,
12 objs::{CommitRef, TagRef, tree::EntryRef},
13 prelude::TreeEntryRefExt,
14 traverse::tree::visit::Action,
15};
16use itertools::Either;
17use moka::future::Cache;
18use std::{
19 borrow::Cow,
20 collections::VecDeque,
21 ffi::OsStr,
22 fmt::{self, Arguments, Write},
23 io::ErrorKind,
24 iter::Copied,
25 path::{Path, PathBuf},
26 str::FromStr,
27 sync::Arc,
28 time::Duration,
29};
30use tar::Builder;
31use time::{OffsetDateTime, UtcOffset};
32use tracing::{error, instrument, warn};
33use yoke::{Yoke, Yokeable};
34
35use crate::{
36 methods::filters::DisplayHexBuffer,
37 syntax_highlight::{ComrakHighlightAdapter, FileIdentifier, format_file, format_file_inner},
38 unified_diff_builder::{Callback, UnifiedDiffBuilder},
39};
40
41type ReadmeCacheKey = (PathBuf, Option<Arc<str>>);
42
43pub struct Git {
44 commits: Cache<(ObjectId, bool), Arc<Commit>, hashbrown::hash_map::DefaultHashBuilder>,
45 readme_cache: Cache<
46 ReadmeCacheKey,
47 Option<(ReadmeFormat, Arc<str>)>,
48 hashbrown::hash_map::DefaultHashBuilder,
49 >,
50 open_repositories:
51 Cache<PathBuf, ThreadSafeRepository, hashbrown::hash_map::DefaultHashBuilder>,
52}
53
54impl Git {
55 #[instrument]
56 pub fn new() -> Self {
57 Self {
58 commits: Cache::builder()
59 .time_to_live(Duration::from_secs(30))
60 .max_capacity(100)
61 .build_with_hasher(hashbrown::hash_map::DefaultHashBuilder::default()),
62 readme_cache: Cache::builder()
63 .time_to_live(Duration::from_secs(30))
64 .max_capacity(100)
65 .build_with_hasher(hashbrown::hash_map::DefaultHashBuilder::default()),
66 open_repositories: Cache::builder()
67 .time_to_idle(Duration::from_secs(120))
68 .max_capacity(100)
69 .build_with_hasher(hashbrown::hash_map::DefaultHashBuilder::default()),
70 }
71 }
72}
73
74impl Git {
75 #[instrument(skip(self))]
76 pub async fn repo(
77 self: Arc<Self>,
78 repo_path: PathBuf,
79 branch: Option<Arc<str>>,
80 ) -> Result<Arc<OpenRepository>> {
81 let repo = repo_path.clone();
82 let repo = self
83 .open_repositories
84 .try_get_with_by_ref(&repo_path, async move {
85 tokio::task::spawn_blocking(move || {
86 gix::open::Options::isolated()
87 .open_path_as_is(true)
88 .open(&repo)
89 })
90 .await
91 .context("Failed to join Tokio task")
92 .map_err(|e| std::io::Error::new(ErrorKind::Other, e))?
93 .map_err(|err| {
94 error!("{}", err);
95 std::io::Error::new(ErrorKind::Other, "Failed to open repository")
96 })
97 })
98 .await?;
99
100 Ok(Arc::new(OpenRepository {
101 git: self,
102 cache_key: repo_path,
103 repo,
104 branch,
105 }))
106 }
107}
108
109pub struct OpenRepository {
110 git: Arc<Git>,
111 cache_key: PathBuf,
112 repo: ThreadSafeRepository,
113 branch: Option<Arc<str>>,
114}
115
116impl OpenRepository {
117 #[allow(clippy::too_many_lines)]
118 pub async fn path(
119 self: Arc<Self>,
120 path: Option<PathBuf>,
121 tree_id: Option<&str>,
122 formatted: bool,
123 ) -> Result<FileWithContent> {
124 let tree_id = tree_id
125 .map(ObjectId::from_str)
126 .transpose()
127 .context("Failed to parse tree hash")?;
128
129 tokio::task::spawn_blocking(move || {
130 let repo = self.repo.to_thread_local();
131
132 let mut tree = if let Some(tree_id) = tree_id {
133 repo.find_tree(tree_id)
134 .context("Couldn't find tree with given id")?
135 } else if let Some(branch) = &self.branch {
136 repo.find_reference(branch.as_ref())?
137 .peel_to_tree()
138 .context("Couldn't find tree for reference")?
139 } else {
140 repo.find_reference("HEAD")
141 .context("Failed to find HEAD")?
142 .peel_to_tree()
143 .context("Couldn't find HEAD for reference")?
144 };
145
146 if let Some(path) = path.as_ref() {
147 let item = tree
148 .peel_to_entry_by_path(path)?
149 .context("Path doesn't exist in tree")?;
150 let object = item.object().context("Path in tree isn't an object")?;
151
152 match object.kind {
153 Kind::Blob => {
154 let mut blob = object.into_blob();
155
156 let content = match (formatted, simdutf8::basic::from_utf8(&blob.data)) {
157 (true, Err(_)) => Content::Binary(vec![]),
158 (true, Ok(data)) => Content::Text(Cow::Owned(format_file(
159 data,
160 FileIdentifier::Path(path.as_path()),
161 )?)),
162 (false, Err(_)) => Content::Binary(blob.take_data()),
163 (false, Ok(_data)) => Content::Text(Cow::Owned(unsafe {
164 String::from_utf8_unchecked(blob.take_data())
165 })),
166 };
167
168 return Ok(FileWithContent { content });
169 }
170 _ => anyhow::bail!("bad object of type {:?}", object.kind),
171 }
172 }
173
174 anyhow::bail!("bad object");
175 })
176 .await
177 .context("Failed to join Tokio task")?
178 }
179
180 #[instrument(skip(self))]
181 pub async fn tag_info(self: Arc<Self>) -> Result<Yoke<DetailedTag<'static>, Vec<u8>>> {
182 tokio::task::spawn_blocking(move || {
183 let tag_name = self.branch.clone().context("no tag given")?;
184 let repo = self.repo.to_thread_local();
185
186 let tag = repo
187 .find_reference(&format!("refs/tags/{tag_name}"))
188 .context("Given tag does not exist in repository")?
189 .peel_to_tag()
190 .context("Couldn't get to a tag from the given reference")?
191 .detach()
192 .data;
193
194 Yoke::try_attach_to_cart(tag, move |tag| {
195 let tag = TagRef::from_bytes(tag)?;
196
197 let tagged_object = match tag.target_kind {
198 Kind::Commit => Some(TaggedObject::Commit(tag.target)),
199 Kind::Tree => Some(TaggedObject::Tree(tag.target)),
200 _ => None,
201 };
202
203 Ok::<_, anyhow::Error>(DetailedTag {
204 name: tag_name,
205 tagger: tag.tagger.map(TryInto::try_into).transpose()?,
206 tagged_object,
207 message: tag.message,
208 })
209 })
210 })
211 .await
212 .context("Failed to join Tokio task")?
213 }
214
215 #[instrument(skip(self))]
216 pub async fn readme(
217 self: Arc<Self>,
218 ) -> Result<Option<(ReadmeFormat, Arc<str>)>, Arc<anyhow::Error>> {
219 const README_FILES: &[&str] = &["README.md", "README", "README.txt"];
220
221 let git = self.git.clone();
222
223 git.readme_cache
224 .try_get_with((self.cache_key.clone(), self.branch.clone()), async move {
225 tokio::task::spawn_blocking(move || {
226 let repo = self.repo.to_thread_local();
227
228 let mut head = if let Some(reference) = &self.branch {
229 repo.find_reference(reference.as_ref())?
230 } else {
231 repo.find_reference("HEAD")
232 .context("Couldn't find HEAD of repository")?
233 };
234
235 let commit = head.peel_to_commit().context(
236 "Couldn't find the commit that the HEAD of the repository refers to",
237 )?;
238 let mut tree = commit
239 .tree()
240 .context("Couldn't get the tree that the HEAD refers to")?;
241
242 for name in README_FILES {
243 let Some(tree_entry) = tree.peel_to_entry_by_path(name)? else {
244 continue;
245 };
246
247 let Some(blob) = tree_entry
248 .object()
249 .ok()
250 .and_then(|v| v.try_into_blob().ok())
251 else {
252 continue;
253 };
254
255 let Ok(content) = simdutf8::basic::from_utf8(&blob.data) else {
256 continue;
257 };
258
259 if Path::new(name).extension().and_then(OsStr::to_str) == Some("md") {
260 let value = parse_and_transform_markdown(content);
261 return Ok(Some((ReadmeFormat::Markdown, Arc::from(value))));
262 }
263
264 return Ok(Some((ReadmeFormat::Plaintext, Arc::from(content))));
265 }
266
267 Ok(None)
268 })
269 .await
270 .context("Failed to join Tokio task")?
271 })
272 .await
273 }
274
275 pub async fn default_branch(self: Arc<Self>) -> Result<Option<String>> {
276 tokio::task::spawn_blocking(move || {
277 let repo = self.repo.to_thread_local();
278 let head = repo.head().context("Couldn't find HEAD of repository")?;
279 Ok(head.referent_name().map(|v| v.shorten().to_string()))
280 })
281 .await
282 .context("Failed to join Tokio task")?
283 }
284
285 #[instrument(skip(self))]
286 pub async fn latest_commit(self: Arc<Self>, highlighted: bool) -> Result<Commit> {
287 tokio::task::spawn_blocking(move || {
288 let repo = self.repo.to_thread_local();
289
290 let mut head = if let Some(reference) = &self.branch {
291 repo.find_reference(reference.as_ref())?
292 } else {
293 repo.find_reference("HEAD")
294 .context("Couldn't find HEAD of repository")?
295 };
296
297 let commit = head
298 .peel_to_commit()
299 .context("Couldn't find commit HEAD of repository refers to")?;
300 let (diff_output, diff_stats) = fetch_diff_and_stats(&repo, &commit, highlighted)?;
301
302 let oid = take_oid(commit.id);
303 let inner = Yoke::try_attach_to_cart(commit.detach().data, |commit| {
304 CommitInner::new(CommitRef::from_bytes(commit)?, oid)
305 })?;
306
307 Ok(Commit {
308 inner,
309 diff_stats,
310 diff: diff_output,
311 })
312 })
313 .await
314 .context("Failed to join Tokio task")?
315 }
316
317 #[instrument(skip_all)]
318 pub async fn archive(
319 self: Arc<Self>,
320 res: tokio::sync::mpsc::Sender<Result<Bytes, anyhow::Error>>,
321 cont: tokio::sync::oneshot::Sender<()>,
322 commit: Option<&str>,
323 ) -> Result<(), anyhow::Error> {
324 let commit = commit
325 .map(ObjectId::from_str)
326 .transpose()
327 .context("failed to build oid")?;
328
329 tokio::task::spawn_blocking(move || {
330 let repo = self.repo.to_thread_local();
331
332 let tree = if let Some(commit) = commit {
333 repo.find_commit(commit)?.tree()?
334 } else if let Some(reference) = &self.branch {
335 repo.find_reference(reference.as_ref())?.peel_to_tree()?
336 } else {
337 repo.find_reference("HEAD")
338 .context("Couldn't find HEAD of repository")?
339 .peel_to_tree()?
340 };
341
342 // tell the web server it can send response headers to the requester
343 if cont.send(()).is_err() {
344 return Err(anyhow!("requester gone"));
345 }
346
347 let buffer = BytesMut::with_capacity(BUFFER_CAP + 1024);
348 let mut visitor = PathVisitor::new(ArchivalVisitor {
349 repository: &repo,
350 res,
351 archive: Builder::new(GzEncoder::new(buffer.writer(), flate2::Compression::fast())),
352 });
353
354 tree.traverse().breadthfirst(&mut visitor)?;
355
356 let visitor = visitor.into_inner();
357
358 visitor.res.blocking_send(Ok(visitor
359 .archive
360 .into_inner()?
361 .finish()?
362 .into_inner()
363 .freeze()))?;
364
365 Ok::<_, anyhow::Error>(())
366 })
367 .await??;
368
369 Ok(())
370 }
371
372 #[instrument(skip(self))]
373 pub async fn commit(
374 self: Arc<Self>,
375 commit: &str,
376 highlighted: bool,
377 ) -> Result<Arc<Commit>, Arc<anyhow::Error>> {
378 let commit = ObjectId::from_str(commit)
379 .map_err(anyhow::Error::from)
380 .map_err(Arc::new)?;
381
382 let git = self.git.clone();
383
384 git.commits
385 .try_get_with((commit, highlighted), async move {
386 tokio::task::spawn_blocking(move || {
387 let repo = self.repo.to_thread_local();
388
389 let commit = repo.find_commit(commit)?;
390
391 let (diff_output, diff_stats) =
392 fetch_diff_and_stats(&repo, &commit, highlighted)?;
393
394 let oid = take_oid(commit.id);
395
396 let inner = Yoke::try_attach_to_cart(commit.detach().data, |commit| {
397 CommitInner::new(CommitRef::from_bytes(commit)?, oid)
398 })?;
399
400 Ok(Arc::new(Commit {
401 inner,
402 diff_stats,
403 diff: diff_output,
404 }))
405 })
406 .await
407 .context("Failed to join Tokio task")?
408 })
409 .await
410 }
411}
412
413fn take_oid(v: ObjectId) -> [u8; 20] {
414 match v {
415 ObjectId::Sha1(v) => v,
416 }
417}
418
419const BUFFER_CAP: usize = 512 * 1024;
420
421pub trait PathVisitorHandler {
422 fn visit(&mut self, entry: &EntryRef<'_>, path: &BStr) -> Action;
423}
424
425struct ArchivalVisitor<'a> {
426 repository: &'a gix::Repository,
427 res: tokio::sync::mpsc::Sender<Result<Bytes, anyhow::Error>>,
428 archive: Builder<GzEncoder<Writer<BytesMut>>>,
429}
430
431impl PathVisitorHandler for ArchivalVisitor<'_> {
432 fn visit(&mut self, entry: &EntryRef<'_>, path: &BStr) -> Action {
433 let entry = entry.attach(self.repository);
434
435 let Ok(object) = entry.object() else {
436 return Action::Continue;
437 };
438
439 if object.kind != Kind::Blob {
440 return Action::Continue;
441 }
442
443 let blob = object.into_blob();
444
445 let mut header = tar::Header::new_gnu();
446 if let Err(error) = header.set_path(path.to_path_lossy()) {
447 warn!(%error, "Attempted to write invalid path to archive");
448 return Action::Continue;
449 }
450 header.set_size(blob.data.len() as u64);
451 #[allow(clippy::cast_sign_loss)]
452 header.set_mode(entry.mode().0.into());
453 header.set_cksum();
454
455 if let Err(error) = self.archive.append(&header, blob.data.as_slice()) {
456 warn!(%error, "Failed to append to archive");
457 return Action::Cancel;
458 }
459
460 if self.archive.get_ref().get_ref().get_ref().len() >= BUFFER_CAP {
461 let b = self.archive.get_mut().get_mut().get_mut().split().freeze();
462
463 if self.res.blocking_send(Ok(b)).is_err() {
464 return Action::Cancel;
465 }
466 }
467
468 Action::Continue
469 }
470}
471
472pub struct PathVisitor<T> {
473 path_deque: VecDeque<BString>,
474 path: BString,
475 inner: T,
476}
477
478impl<T> PathVisitor<T> {
479 pub fn new(inner: T) -> Self {
480 Self {
481 path_deque: VecDeque::new(),
482 path: BString::default(),
483 inner,
484 }
485 }
486
487 pub fn into_inner(self) -> T {
488 self.inner
489 }
490
491 fn pop_element(&mut self) {
492 if let Some(pos) = memchr::memrchr(b'/', &self.path) {
493 self.path.resize(pos, 0);
494 } else {
495 self.path.clear();
496 }
497 }
498
499 fn push_element(&mut self, name: &BStr) {
500 if name.is_empty() {
501 return;
502 }
503 if !self.path.is_empty() {
504 self.path.push(b'/');
505 }
506 self.path.push_str(name);
507 }
508}
509
510impl<T: PathVisitorHandler> gix::traverse::tree::Visit for PathVisitor<T> {
511 fn pop_front_tracked_path_and_set_current(&mut self) {
512 self.path = self
513 .path_deque
514 .pop_front()
515 .expect("every call is matched with push_tracked_path_component");
516 }
517
518 fn pop_back_tracked_path_and_set_current(&mut self) {
519 self.path = self.path_deque.pop_back().unwrap_or_default();
520 }
521
522 fn push_back_tracked_path_component(&mut self, component: &BStr) {
523 self.push_element(component);
524 self.path_deque.push_back(self.path.clone());
525 }
526
527 fn push_path_component(&mut self, component: &BStr) {
528 self.push_element(component);
529 }
530
531 fn pop_path_component(&mut self) {
532 self.pop_element();
533 }
534
535 fn visit_tree(&mut self, entry: &EntryRef<'_>) -> Action {
536 self.inner.visit(entry, self.path.as_ref())
537 }
538
539 fn visit_nontree(&mut self, entry: &EntryRef<'_>) -> Action {
540 self.inner.visit(entry, self.path.as_ref())
541 }
542}
543
544fn parse_and_transform_markdown(s: &str) -> String {
545 let mut plugins = ComrakPlugins::default();
546
547 plugins.render.codefence_syntax_highlighter = Some(&ComrakHighlightAdapter);
548
549 // enable gfm extensions
550 // https://github.github.com/gfm/
551 let mut options = Options::default();
552 options.extension.autolink = true;
553 options.extension.footnotes = true;
554 options.extension.strikethrough = true;
555 options.extension.table = true;
556 options.extension.tagfilter = true;
557 options.extension.tasklist = true;
558
559 comrak::markdown_to_html_with_plugins(s, &options, &plugins)
560}
561
562#[derive(Copy, Clone, Debug, PartialEq, Eq)]
563pub enum ReadmeFormat {
564 Markdown,
565 Plaintext,
566}
567
568#[derive(Debug)]
569#[allow(unused)]
570pub struct FileWithContent {
571 pub content: Content,
572}
573
574#[derive(Debug)]
575pub enum Content {
576 Text(Cow<'static, str>),
577 Binary(Vec<u8>),
578}
579
580impl IntoResponse for Content {
581 fn into_response(self) -> axum::response::Response {
582 use axum::http;
583
584 match self {
585 Self::Text(t) => {
586 let headers = [(
587 http::header::CONTENT_TYPE,
588 http::HeaderValue::from_static("text/plain; charset=UTF-8"),
589 )];
590
591 (headers, t).into_response()
592 }
593 Self::Binary(b) => {
594 let headers = [(
595 http::header::CONTENT_TYPE,
596 http::HeaderValue::from_static("application/octet-stream"),
597 )];
598
599 (headers, b).into_response()
600 }
601 }
602 }
603}
604
605#[derive(Debug)]
606pub enum TaggedObject<'a> {
607 Commit(&'a BStr),
608 Tree(&'a BStr),
609}
610
611#[derive(Debug, Yokeable)]
612pub struct DetailedTag<'a> {
613 pub name: Arc<str>,
614 pub tagger: Option<CommitUser<'a>>,
615 pub message: &'a BStr,
616 pub tagged_object: Option<TaggedObject<'a>>,
617}
618
619#[derive(Debug)]
620pub struct CommitUser<'a> {
621 name: &'a BStr,
622 email: &'a BStr,
623 time: (i64, i32),
624}
625
626impl<'a> TryFrom<SignatureRef<'a>> for CommitUser<'a> {
627 type Error = anyhow::Error;
628
629 fn try_from(v: SignatureRef<'a>) -> Result<Self> {
630 Ok(CommitUser {
631 name: v.name,
632 email: v.email,
633 time: (v.time.seconds, v.time.offset),
634 // time: OffsetDateTime::from_unix_timestamp(v.time.seconds)?
635 // .to_offset(UtcOffset::from_whole_seconds(v.time.offset)?),
636 })
637 }
638}
639
640impl CommitUser<'_> {
641 pub fn name(&self) -> &BStr {
642 self.name
643 }
644
645 pub fn email(&self) -> &BStr {
646 self.email
647 }
648
649 pub fn time(&self) -> OffsetDateTime {
650 OffsetDateTime::from_unix_timestamp(self.time.0)
651 .unwrap()
652 .to_offset(UtcOffset::from_whole_seconds(self.time.1).unwrap())
653 }
654}
655
656#[derive(Debug)]
657pub struct Commit {
658 inner: yoke::Yoke<CommitInner<'static>, Vec<u8>>,
659 pub diff_stats: String,
660 pub diff: String,
661}
662
663impl Commit {
664 pub fn get(&self) -> &CommitInner<'_> {
665 self.inner.get()
666 }
667}
668
669#[derive(Debug, Yokeable)]
670pub struct CommitInner<'a> {
671 author: CommitUser<'a>,
672 committer: CommitUser<'a>,
673 oid: [u8; 20],
674 tree: &'a BStr,
675 parents: SmallVec<&'a BStr>,
676 summary: Cow<'a, BStr>,
677 body: &'a BStr,
678}
679
680#[derive(Debug)]
681enum SmallVec<T> {
682 None,
683 One(T),
684 Many(Vec<T>),
685}
686
687impl<T: Copy> SmallVec<T> {
688 #[allow(clippy::type_complexity)]
689 fn iter(
690 &self,
691 ) -> Either<std::iter::Empty<T>, Either<std::iter::Once<T>, Copied<std::slice::Iter<T>>>> {
692 match self {
693 Self::None => Either::Left(std::iter::empty()),
694 Self::One(v) => Either::Right(Either::Left(std::iter::once(*v))),
695 Self::Many(v) => Either::Right(Either::Right(v.iter().copied())),
696 }
697 }
698}
699
700impl<'a> CommitInner<'a> {
701 pub fn new(commit: gix::worktree::object::CommitRef<'a>, oid: [u8; 20]) -> Result<Self> {
702 let message = commit.message();
703
704 Ok(CommitInner {
705 author: CommitUser::try_from(commit.author)?,
706 committer: CommitUser::try_from(commit.committer)?,
707 oid,
708 tree: commit.tree,
709 parents: commit.parents.into_inner().map_or_else(
710 |inner| {
711 if inner.is_empty() {
712 SmallVec::None
713 } else {
714 SmallVec::Many(inner.into_vec())
715 }
716 },
717 |[v]| SmallVec::One(v),
718 ),
719
720 summary: message.summary(),
721 body: message.body.unwrap_or_else(|| BStr::new("")),
722 })
723 }
724}
725
726impl CommitInner<'_> {
727 pub fn author(&self) -> &CommitUser<'_> {
728 &self.author
729 }
730
731 pub fn committer(&self) -> &CommitUser<'_> {
732 &self.committer
733 }
734
735 pub fn oid(&self) -> DisplayHexBuffer<20> {
736 let mut buf = const_hex::Buffer::new();
737 buf.format(&self.oid);
738 DisplayHexBuffer(buf)
739 }
740
741 pub fn tree(&self) -> &BStr {
742 self.tree
743 }
744
745 pub fn parents(&self) -> impl Iterator<Item = &BStr> {
746 self.parents.iter()
747 }
748
749 pub fn summary(&self) -> &BStr {
750 &self.summary
751 }
752
753 pub fn body(&self) -> &BStr {
754 self.body
755 }
756}
757
758#[instrument(skip(repo, commit))]
759fn fetch_diff_and_stats(
760 repo: &gix::Repository,
761 commit: &gix::Commit<'_>,
762 highlight: bool,
763) -> Result<(String, String)> {
764 const WIDTH: usize = 80;
765
766 let current_tree = commit.tree().context("Couldn't get tree for the commit")?;
767 let parent_tree = commit
768 .ancestors()
769 .first_parent_only()
770 .all()?
771 .nth(1)
772 .transpose()?
773 .map(|v| v.object())
774 .transpose()?
775 .map(|v| v.tree())
776 .transpose()?
777 .unwrap_or_else(|| repo.empty_tree());
778
779 let mut diffs = Vec::new();
780 let mut diff_output = String::new();
781
782 let mut resource_cache = repo.diff_resource_cache_for_tree_diff()?;
783
784 let mut changes = parent_tree.changes()?;
785 changes.options(|opts| {
786 opts.track_path().track_rewrites(None);
787 });
788 changes.for_each_to_obtain_tree_with_cache(
789 ¤t_tree,
790 &mut repo.diff_resource_cache_for_tree_diff()?,
791 |change| {
792 if highlight {
793 DiffBuilder {
794 output: &mut diff_output,
795 resource_cache: &mut resource_cache,
796 diffs: &mut diffs,
797 formatter: SyntaxHighlightedDiffFormatter::new(
798 change.location().to_path().unwrap(),
799 ),
800 }
801 .handle(change)
802 } else {
803 DiffBuilder {
804 output: &mut diff_output,
805 resource_cache: &mut resource_cache,
806 diffs: &mut diffs,
807 formatter: PlainDiffFormatter,
808 }
809 .handle(change)
810 }
811 },
812 )?;
813
814 let (max_file_name_length, max_change_length, files_changed, insertions, deletions) =
815 diffs.iter().fold(
816 (0, 0, 0, 0, 0),
817 |(max_file_name_length, max_change_length, files_changed, insertions, deletions),
818 stats| {
819 (
820 max_file_name_length.max(stats.path.len()),
821 max_change_length
822 .max(((stats.insertions + stats.deletions + 1).ilog10() + 1) as usize),
823 files_changed + 1,
824 insertions + stats.insertions,
825 deletions + stats.deletions,
826 )
827 },
828 );
829
830 let mut diff_stats = String::new();
831
832 let total_changes = insertions + deletions;
833
834 for diff in &diffs {
835 let local_changes = diff.insertions + diff.deletions;
836 let width = WIDTH.min(local_changes);
837
838 // Calculate proportions of `+` and `-` within the total width
839 let addition_width = (width * diff.insertions) / total_changes;
840 let deletion_width = (width * diff.deletions) / total_changes;
841
842 // Handle edge case where total width is less than total changes
843 let remaining_width = width - (addition_width + deletion_width);
844 let adjusted_addition_width = addition_width + remaining_width.min(diff.insertions);
845 let adjusted_deletion_width =
846 deletion_width + (remaining_width - remaining_width.min(diff.insertions));
847
848 // Generate the string representation
849 let plus_str = "+".repeat(adjusted_addition_width);
850 let minus_str = "-".repeat(adjusted_deletion_width);
851
852 let file = diff.path.as_str();
853 writeln!(diff_stats, " {file:max_file_name_length$} | {local_changes:max_change_length$} {plus_str}{minus_str}").unwrap();
854 }
855
856 for (i, (singular_desc, plural_desc, amount)) in [
857 ("file changed", "files changed", files_changed),
858 ("insertion(+)", "insertions(+)", insertions),
859 ("deletion(-)", "deletions(-)", deletions),
860 ]
861 .into_iter()
862 .enumerate()
863 {
864 if amount == 0 {
865 continue;
866 }
867
868 let prefix = if i == 0 { "" } else { "," };
869
870 let desc = if amount == 1 {
871 singular_desc
872 } else {
873 plural_desc
874 };
875
876 write!(diff_stats, "{prefix} {amount} {desc}")?;
877 }
878
879 // TODO: emit 'create mode 100644 pure-black-background-f82588d3.jpg' here
880
881 writeln!(diff_stats)?;
882
883 Ok((diff_output, diff_stats))
884}
885
886#[derive(Default, Debug)]
887struct FileDiff {
888 path: String,
889 insertions: usize,
890 deletions: usize,
891}
892
893trait DiffFormatter {
894 fn file_header(&self, output: &mut String, data: fmt::Arguments<'_>);
895
896 fn binary(
897 &self,
898 output: &mut String,
899 left: &str,
900 right: &str,
901 left_content: &[u8],
902 right_content: &[u8],
903 );
904}
905
906struct DiffBuilder<'a, F> {
907 output: &'a mut String,
908 resource_cache: &'a mut gix::diff::blob::Platform,
909 diffs: &'a mut Vec<FileDiff>,
910 formatter: F,
911}
912
913impl<F: DiffFormatter + Callback> DiffBuilder<'_, F> {
914 #[allow(clippy::too_many_lines)]
915 fn handle(
916 &mut self,
917 change: gix::object::tree::diff::Change<'_, '_, '_>,
918 ) -> Result<gix::object::tree::diff::Action> {
919 if !change.entry_mode().is_blob_or_symlink() {
920 return Ok(gix::object::tree::diff::Action::Continue);
921 }
922
923 let mut diff = FileDiff {
924 path: change.location().to_string(),
925 insertions: 0,
926 deletions: 0,
927 };
928 let change = change.diff(self.resource_cache)?;
929
930 let prep = change.resource_cache.prepare_diff()?;
931
932 self.formatter.file_header(
933 self.output,
934 format_args!(
935 "diff --git a/{} b/{}",
936 prep.old.rela_path, prep.new.rela_path
937 ),
938 );
939
940 if prep.old.id.is_null() {
941 self.formatter.file_header(
942 self.output,
943 format_args!("new file mode {}", prep.new.mode.as_octal_str()),
944 );
945 } else if prep.new.id.is_null() {
946 self.formatter.file_header(
947 self.output,
948 format_args!("deleted file mode {}", prep.old.mode.as_octal_str()),
949 );
950 } else if prep.new.mode != prep.old.mode {
951 self.formatter.file_header(
952 self.output,
953 format_args!("old mode {}", prep.old.mode.as_octal_str()),
954 );
955 self.formatter.file_header(
956 self.output,
957 format_args!("new mode {}", prep.new.mode.as_octal_str()),
958 );
959 }
960
961 // copy from
962 // copy to
963 // rename old
964 // rename new
965 // rename from
966 // rename to
967 // similarity index
968 // dissimilarity index
969
970 let (index_suffix_sep, index_suffix) = if prep.old.mode == prep.new.mode {
971 (" ", prep.new.mode.as_octal_str())
972 } else {
973 ("", BStr::new(&[]))
974 };
975
976 let old_path = if prep.old.id.is_null() {
977 Cow::Borrowed("/dev/null")
978 } else {
979 Cow::Owned(format!("a/{}", prep.old.rela_path))
980 };
981
982 let new_path = if prep.new.id.is_null() {
983 Cow::Borrowed("/dev/null")
984 } else {
985 Cow::Owned(format!("a/{}", prep.new.rela_path))
986 };
987
988 match prep.operation {
989 Operation::InternalDiff { algorithm } => {
990 self.formatter.file_header(
991 self.output,
992 format_args!(
993 "index {}..{}{index_suffix_sep}{index_suffix}",
994 prep.old.id.to_hex_with_len(7),
995 prep.new.id.to_hex_with_len(7)
996 ),
997 );
998 self.formatter
999 .file_header(self.output, format_args!("--- {old_path}"));
1000 self.formatter
1001 .file_header(self.output, format_args!("+++ {new_path}"));
1002
1003 let old_source = gix::diff::blob::sources::lines_with_terminator(
1004 simdutf8::basic::from_utf8(prep.old.data.as_slice().unwrap_or_default())?,
1005 );
1006 let new_source = gix::diff::blob::sources::lines_with_terminator(
1007 simdutf8::basic::from_utf8(prep.new.data.as_slice().unwrap_or_default())?,
1008 );
1009 let input = gix::diff::blob::intern::InternedInput::new(old_source, new_source);
1010
1011 let output = gix::diff::blob::diff(
1012 algorithm,
1013 &input,
1014 UnifiedDiffBuilder::with_writer(&input, &mut *self.output, &mut self.formatter)
1015 .with_counter(),
1016 );
1017
1018 diff.deletions += output.removals as usize;
1019 diff.insertions += output.insertions as usize;
1020 }
1021 Operation::ExternalCommand { .. } => {}
1022 Operation::SourceOrDestinationIsBinary => {
1023 self.formatter.file_header(
1024 self.output,
1025 format_args!(
1026 "index {}..{}{index_suffix_sep}{index_suffix}",
1027 prep.old.id, prep.new.id,
1028 ),
1029 );
1030
1031 self.formatter.binary(
1032 self.output,
1033 old_path.as_ref(),
1034 new_path.as_ref(),
1035 prep.old.data.as_slice().unwrap_or_default(),
1036 prep.new.data.as_slice().unwrap_or_default(),
1037 );
1038 }
1039 }
1040
1041 self.diffs.push(diff);
1042
1043 self.resource_cache.clear_resource_cache_keep_allocation();
1044 Ok(gix::object::tree::diff::Action::Continue)
1045 }
1046}
1047
1048struct PlainDiffFormatter;
1049
1050impl DiffFormatter for PlainDiffFormatter {
1051 fn file_header(&self, output: &mut String, data: fmt::Arguments<'_>) {
1052 writeln!(output, "{data}").unwrap();
1053 }
1054
1055 fn binary(
1056 &self,
1057 output: &mut String,
1058 left: &str,
1059 right: &str,
1060 _left_content: &[u8],
1061 _right_content: &[u8],
1062 ) {
1063 // todo: actually perform the diff and write a `GIT binary patch` out
1064 writeln!(output, "Binary files {left} and {right} differ").unwrap();
1065 }
1066}
1067
1068impl Callback for PlainDiffFormatter {
1069 fn addition(&mut self, data: &str, dst: &mut String) {
1070 write!(dst, "+{data}").unwrap();
1071 }
1072
1073 fn remove(&mut self, data: &str, dst: &mut String) {
1074 write!(dst, "-{data}").unwrap();
1075 }
1076
1077 fn context(&mut self, data: &str, dst: &mut String) {
1078 write!(dst, " {data}").unwrap();
1079 }
1080}
1081
1082struct SyntaxHighlightedDiffFormatter<'a> {
1083 path: &'a Path,
1084}
1085
1086impl<'a> SyntaxHighlightedDiffFormatter<'a> {
1087 fn new(path: &'a Path) -> Self {
1088 Self { path }
1089 }
1090
1091 fn write(&self, output: &mut String, class: &str, data: &str) {
1092 write!(output, r#"<span class="diff-{class}">"#).unwrap();
1093 format_file_inner(output, data, FileIdentifier::Path(self.path), false).unwrap();
1094 output.push_str("</span>");
1095 }
1096}
1097
1098impl DiffFormatter for SyntaxHighlightedDiffFormatter<'_> {
1099 fn file_header(&self, output: &mut String, data: Arguments<'_>) {
1100 write!(output, r#"<span class="diff-file-header">"#).unwrap();
1101 write!(output, "{data}").unwrap();
1102 output.push_str("</span>\n");
1103 }
1104
1105 fn binary(
1106 &self,
1107 output: &mut String,
1108 left: &str,
1109 right: &str,
1110 _left_content: &[u8],
1111 _right_content: &[u8],
1112 ) {
1113 write!(output, "Binary files {left} and {right} differ").unwrap();
1114 }
1115}
1116
1117impl Callback for SyntaxHighlightedDiffFormatter<'_> {
1118 fn addition(&mut self, data: &str, dst: &mut String) {
1119 self.write(dst, "add-line", data);
1120 }
1121
1122 fn remove(&mut self, data: &str, dst: &mut String) {
1123 self.write(dst, "remove-line", data);
1124 }
1125
1126 fn context(&mut self, data: &str, dst: &mut String) {
1127 self.write(dst, "context", data);
1128 }
1129}