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