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