1use std::{
2 borrow::Cow,
3 ffi::OsStr,
4 fmt::Write,
5 path::{Path, PathBuf},
6 sync::Arc,
7 time::Duration,
8};
9
10use anyhow::{anyhow, Context, Result};
11use axum::response::IntoResponse;
12use bytes::{BufMut, Bytes, BytesMut};
13use comrak::{ComrakPlugins, Options};
14use git2::{
15 DiffFormat, DiffLineType, DiffOptions, DiffStatsFormat, Email, EmailCreateOptions, ObjectType,
16 Oid, Signature, TreeWalkResult,
17};
18use moka::future::Cache;
19use parking_lot::Mutex;
20use syntect::{
21 html::{ClassStyle, ClassedHTMLGenerator},
22 parsing::SyntaxSet,
23 util::LinesWithEndings,
24};
25use time::OffsetDateTime;
26use tracing::{error, instrument, warn};
27
28use crate::syntax_highlight::ComrakSyntectAdapter;
29
30type ReadmeCacheKey = (PathBuf, Option<Arc<str>>);
31
32pub struct Git {
33 commits: Cache<Oid, Arc<Commit>>,
34 readme_cache: Cache<ReadmeCacheKey, Option<(ReadmeFormat, Arc<str>)>>,
35 syntax_set: SyntaxSet,
36}
37
38impl Git {
39 #[instrument(skip(syntax_set))]
40 pub fn new(syntax_set: SyntaxSet) -> Self {
41 Self {
42 commits: Cache::builder()
43 .time_to_live(Duration::from_secs(10))
44 .max_capacity(100)
45 .build(),
46 readme_cache: Cache::builder()
47 .time_to_live(Duration::from_secs(10))
48 .max_capacity(100)
49 .build(),
50 syntax_set,
51 }
52 }
53}
54
55impl Git {
56 #[instrument(skip(self))]
57 pub async fn repo(
58 self: Arc<Self>,
59 repo_path: PathBuf,
60 branch: Option<Arc<str>>,
61 ) -> Result<Arc<OpenRepository>> {
62 let repo = tokio::task::spawn_blocking({
63 let repo_path = repo_path.clone();
64 move || git2::Repository::open(repo_path)
65 })
66 .await
67 .context("Failed to join Tokio task")?
68 .map_err(|err| {
69 error!("{}", err);
70 anyhow!("Failed to open repository")
71 })?;
72
73 Ok(Arc::new(OpenRepository {
74 git: self,
75 cache_key: repo_path,
76 repo: Mutex::new(repo),
77 branch,
78 }))
79 }
80}
81
82pub struct OpenRepository {
83 git: Arc<Git>,
84 cache_key: PathBuf,
85 repo: Mutex<git2::Repository>,
86 branch: Option<Arc<str>>,
87}
88
89impl OpenRepository {
90 pub async fn path(
91 self: Arc<Self>,
92 path: Option<PathBuf>,
93 tree_id: Option<&str>,
94 formatted: bool,
95 ) -> Result<PathDestination> {
96 let tree_id = tree_id
97 .map(Oid::from_str)
98 .transpose()
99 .context("Failed to parse tree hash")?;
100
101 tokio::task::spawn_blocking(move || {
102 let repo = self.repo.lock();
103
104 let mut tree = if let Some(tree_id) = tree_id {
105 repo.find_tree(tree_id)
106 .context("Couldn't find tree with given id")?
107 } else if let Some(branch) = &self.branch {
108 let reference = repo.resolve_reference_from_short_name(branch)?;
109 reference
110 .peel_to_tree()
111 .context("Couldn't find tree for reference")?
112 } else {
113 let head = repo.head().context("Failed to find HEAD")?;
114 head.peel_to_tree()
115 .context("Couldn't find tree from HEAD")?
116 };
117
118 if let Some(path) = path.as_ref() {
119 let item = tree.get_path(path).context("Path doesn't exist in tree")?;
120 let object = item
121 .to_object(&repo)
122 .context("Path in tree isn't an object")?;
123
124 if let Some(blob) = object.as_blob() {
125 // TODO: use Path here instead of a lossy utf8 conv
126 let name = String::from_utf8_lossy(item.name_bytes());
127 let path = path.clone().join(&*name);
128
129 let extension = path
130 .extension()
131 .or_else(|| path.file_name())
132 .map_or_else(|| Cow::Borrowed(""), OsStr::to_string_lossy);
133 let content = match (formatted, blob.is_binary()) {
134 (true, true) => Content::Binary(vec![]),
135 (true, false) => Content::Text(
136 format_file(blob.content(), &extension, &self.git.syntax_set)?.into(),
137 ),
138 (false, true) => Content::Binary(blob.content().to_vec()),
139 (false, false) => Content::Text(
140 String::from_utf8_lossy(blob.content()).to_string().into(),
141 ),
142 };
143
144 return Ok(PathDestination::File(FileWithContent {
145 metadata: File {
146 mode: item.filemode(),
147 size: blob.size(),
148 path,
149 name: name.into_owned(),
150 },
151 content,
152 }));
153 } else if let Ok(new_tree) = object.into_tree() {
154 tree = new_tree;
155 } else {
156 anyhow::bail!("Given path not tree nor blob... what is it?!");
157 }
158 }
159
160 let mut tree_items = Vec::new();
161
162 for item in &tree {
163 let object = item
164 .to_object(&repo)
165 .context("Expected item in tree to be object but it wasn't")?;
166
167 let name = String::from_utf8_lossy(item.name_bytes()).into_owned();
168 let path = path.clone().unwrap_or_default().join(&name);
169
170 if let Some(blob) = object.as_blob() {
171 tree_items.push(TreeItem::File(File {
172 mode: item.filemode(),
173 size: blob.size(),
174 path,
175 name,
176 }));
177 } else if let Some(_tree) = object.as_tree() {
178 tree_items.push(TreeItem::Tree(Tree {
179 mode: item.filemode(),
180 path,
181 name,
182 }));
183 }
184 }
185
186 Ok(PathDestination::Tree(tree_items))
187 })
188 .await
189 .context("Failed to join Tokio task")?
190 }
191
192 #[instrument(skip(self))]
193 pub async fn tag_info(self: Arc<Self>) -> Result<DetailedTag> {
194 tokio::task::spawn_blocking(move || {
195 let tag_name = self.branch.clone().context("no tag given")?;
196 let repo = self.repo.lock();
197
198 let tag = repo
199 .find_reference(&format!("refs/tags/{tag_name}"))
200 .context("Given tag does not exist in repository")?
201 .peel_to_tag()
202 .context("Couldn't get to a tag from the given reference")?;
203 let tag_target = tag.target().context("Couldn't find tagged object")?;
204
205 let tagged_object = match tag_target.kind() {
206 Some(ObjectType::Commit) => Some(TaggedObject::Commit(tag_target.id().to_string())),
207 Some(ObjectType::Tree) => Some(TaggedObject::Tree(tag_target.id().to_string())),
208 None | Some(_) => None,
209 };
210
211 Ok(DetailedTag {
212 name: tag_name,
213 tagger: tag.tagger().map(TryInto::try_into).transpose()?,
214 message: tag
215 .message_bytes()
216 .map_or_else(|| Cow::Borrowed(""), String::from_utf8_lossy)
217 .into_owned(),
218 tagged_object,
219 })
220 })
221 .await
222 .context("Failed to join Tokio task")?
223 }
224
225 #[instrument(skip(self))]
226 pub async fn readme(
227 self: Arc<Self>,
228 ) -> Result<Option<(ReadmeFormat, Arc<str>)>, Arc<anyhow::Error>> {
229 const README_FILES: &[&str] = &["README.md", "README", "README.txt"];
230
231 let git = self.git.clone();
232
233 git.readme_cache
234 .try_get_with((self.cache_key.clone(), self.branch.clone()), async move {
235 tokio::task::spawn_blocking(move || {
236 let repo = self.repo.lock();
237
238 let head = if let Some(reference) = &self.branch {
239 repo.resolve_reference_from_short_name(reference)?
240 } else {
241 repo.head().context("Couldn't find HEAD of repository")?
242 };
243
244 let commit = head.peel_to_commit().context(
245 "Couldn't find the commit that the HEAD of the repository refers to",
246 )?;
247 let tree = commit
248 .tree()
249 .context("Couldn't get the tree that the HEAD refers to")?;
250
251 for name in README_FILES {
252 let Some(tree_entry) = tree.get_name(name) else {
253 continue;
254 };
255
256 let Some(blob) = tree_entry
257 .to_object(&repo)
258 .ok()
259 .and_then(|v| v.into_blob().ok())
260 else {
261 continue;
262 };
263
264 let Ok(content) = std::str::from_utf8(blob.content()) else {
265 continue;
266 };
267
268 if Path::new(name).extension().and_then(OsStr::to_str) == Some("md") {
269 let value = parse_and_transform_markdown(content, &self.git.syntax_set);
270 return Ok(Some((ReadmeFormat::Markdown, Arc::from(value))));
271 }
272
273 return Ok(Some((ReadmeFormat::Plaintext, Arc::from(content))));
274 }
275
276 Ok(None)
277 })
278 .await
279 .context("Failed to join Tokio task")?
280 })
281 .await
282 }
283
284 pub async fn default_branch(self: Arc<Self>) -> Result<Option<String>> {
285 tokio::task::spawn_blocking(move || {
286 let repo = self.repo.lock();
287 let head = repo.head().context("Couldn't find HEAD of repository")?;
288 Ok(head.shorthand().map(ToString::to_string))
289 })
290 .await
291 .context("Failed to join Tokio task")?
292 }
293
294 #[instrument(skip(self))]
295 pub async fn latest_commit(self: Arc<Self>) -> Result<Commit> {
296 tokio::task::spawn_blocking(move || {
297 let repo = self.repo.lock();
298
299 let head = if let Some(reference) = &self.branch {
300 repo.resolve_reference_from_short_name(reference)?
301 } else {
302 repo.head().context("Couldn't find HEAD of repository")?
303 };
304
305 let commit = head
306 .peel_to_commit()
307 .context("Couldn't find commit HEAD of repository refers to")?;
308 let (diff_plain, diff_output, diff_stats) =
309 fetch_diff_and_stats(&repo, &commit, &self.git.syntax_set)?;
310
311 let mut commit = Commit::try_from(commit)?;
312 commit.diff_stats = diff_stats;
313 commit.diff = diff_output;
314 commit.diff_plain = diff_plain;
315 Ok(commit)
316 })
317 .await
318 .context("Failed to join Tokio task")?
319 }
320
321 #[instrument(skip_all)]
322 pub async fn archive(
323 self: Arc<Self>,
324 res: tokio::sync::mpsc::Sender<Result<Bytes, anyhow::Error>>,
325 cont: tokio::sync::oneshot::Sender<()>,
326 commit: Option<&str>,
327 ) -> Result<(), anyhow::Error> {
328 const BUFFER_CAP: usize = 512 * 1024;
329
330 let commit = commit
331 .map(Oid::from_str)
332 .transpose()
333 .context("failed to build oid")?;
334
335 tokio::task::spawn_blocking(move || {
336 let buffer = BytesMut::with_capacity(BUFFER_CAP + 1024);
337
338 let flate = flate2::write::GzEncoder::new(buffer.writer(), flate2::Compression::fast());
339 let mut archive = tar::Builder::new(flate);
340
341 let repo = self.repo.lock();
342
343 let tree = if let Some(commit) = commit {
344 repo.find_commit(commit)?.tree()?
345 } else if let Some(reference) = &self.branch {
346 repo.resolve_reference_from_short_name(reference)?
347 .peel_to_tree()?
348 } else {
349 repo.head()
350 .context("Couldn't find HEAD of repository")?
351 .peel_to_tree()?
352 };
353
354 // tell the web server it can send response headers to the requester
355 if cont.send(()).is_err() {
356 return Err(anyhow!("requester gone"));
357 }
358
359 let mut callback = |root: &str, entry: &git2::TreeEntry| -> TreeWalkResult {
360 if let Ok(blob) = entry.to_object(&repo).unwrap().peel_to_blob() {
361 let path =
362 Path::new(root).join(String::from_utf8_lossy(entry.name_bytes()).as_ref());
363
364 let mut header = tar::Header::new_gnu();
365 if let Err(error) = header.set_path(&path) {
366 warn!(%error, "Attempted to write invalid path to archive");
367 return TreeWalkResult::Skip;
368 }
369 header.set_size(blob.size() as u64);
370 #[allow(clippy::cast_sign_loss)]
371 header.set_mode(entry.filemode() as u32);
372 header.set_cksum();
373
374 if let Err(error) = archive.append(&header, blob.content()) {
375 error!(%error, "Failed to write blob to archive");
376 return TreeWalkResult::Abort;
377 }
378 }
379
380 if archive.get_ref().get_ref().get_ref().len() >= BUFFER_CAP {
381 let b = archive.get_mut().get_mut().get_mut().split().freeze();
382 if let Err(error) = res.blocking_send(Ok(b)) {
383 error!(%error, "Failed to send buffer to client");
384 return TreeWalkResult::Abort;
385 }
386 }
387
388 TreeWalkResult::Ok
389 };
390
391 tree.walk(git2::TreeWalkMode::PreOrder, &mut callback)?;
392
393 res.blocking_send(Ok(archive.into_inner()?.finish()?.into_inner().freeze()))?;
394
395 Ok::<_, anyhow::Error>(())
396 })
397 .await??;
398
399 Ok(())
400 }
401
402 #[instrument(skip(self))]
403 pub async fn commit(self: Arc<Self>, commit: &str) -> Result<Arc<Commit>, Arc<anyhow::Error>> {
404 let commit = Oid::from_str(commit)
405 .map_err(anyhow::Error::from)
406 .map_err(Arc::new)?;
407
408 let git = self.git.clone();
409
410 git.commits
411 .try_get_with(commit, async move {
412 tokio::task::spawn_blocking(move || {
413 let repo = self.repo.lock();
414
415 let commit = repo.find_commit(commit)?;
416 let (diff_plain, diff_output, diff_stats) =
417 fetch_diff_and_stats(&repo, &commit, &self.git.syntax_set)?;
418
419 let mut commit = Commit::try_from(commit)?;
420 commit.diff_stats = diff_stats;
421 commit.diff = diff_output;
422 commit.diff_plain = diff_plain;
423
424 Ok(Arc::new(commit))
425 })
426 .await
427 .context("Failed to join Tokio task")?
428 })
429 .await
430 }
431}
432
433fn parse_and_transform_markdown(s: &str, syntax_set: &SyntaxSet) -> String {
434 let mut plugins = ComrakPlugins::default();
435
436 let highlighter = ComrakSyntectAdapter { syntax_set };
437 plugins.render.codefence_syntax_highlighter = Some(&highlighter);
438
439 // enable gfm extensions
440 // https://github.github.com/gfm/
441 let mut options = Options::default();
442 options.extension.autolink = true;
443 options.extension.footnotes = true;
444 options.extension.strikethrough = true;
445 options.extension.table = true;
446 options.extension.tagfilter = true;
447 options.extension.tasklist = true;
448
449 comrak::markdown_to_html_with_plugins(s, &options, &plugins)
450}
451
452#[derive(Copy, Clone, Debug, PartialEq, Eq)]
453pub enum ReadmeFormat {
454 Markdown,
455 Plaintext,
456}
457
458pub enum PathDestination {
459 Tree(Vec<TreeItem>),
460 File(FileWithContent),
461}
462
463pub enum TreeItem {
464 Tree(Tree),
465 File(File),
466}
467
468#[derive(Debug)]
469pub struct Tree {
470 pub mode: i32,
471 pub name: String,
472 pub path: PathBuf,
473}
474
475#[derive(Debug)]
476pub struct File {
477 pub mode: i32,
478 pub size: usize,
479 pub name: String,
480 pub path: PathBuf,
481}
482
483#[derive(Debug)]
484pub struct FileWithContent {
485 pub metadata: File,
486 pub content: Content,
487}
488
489#[derive(Debug)]
490pub enum Content {
491 Text(Cow<'static, str>),
492 Binary(Vec<u8>),
493}
494
495impl IntoResponse for Content {
496 fn into_response(self) -> axum::response::Response {
497 use axum::http;
498
499 match self {
500 Self::Text(t) => {
501 let headers = [(
502 http::header::CONTENT_TYPE,
503 http::HeaderValue::from_static("text/plain; charset=UTF-8"),
504 )];
505
506 (headers, t).into_response()
507 }
508 Self::Binary(b) => {
509 let headers = [(
510 http::header::CONTENT_TYPE,
511 http::HeaderValue::from_static("application/octet-stream"),
512 )];
513
514 (headers, b).into_response()
515 }
516 }
517 }
518}
519
520#[derive(Debug)]
521pub struct Branch {
522 pub name: String,
523 pub commit: Commit,
524}
525
526#[derive(Debug)]
527pub struct Remote {
528 pub name: String,
529}
530
531#[derive(Debug)]
532pub enum TaggedObject {
533 Commit(String),
534 Tree(String),
535}
536
537#[derive(Debug)]
538pub struct DetailedTag {
539 pub name: Arc<str>,
540 pub tagger: Option<CommitUser>,
541 pub message: String,
542 pub tagged_object: Option<TaggedObject>,
543}
544
545#[derive(Debug)]
546pub struct CommitUser {
547 name: String,
548 email: String,
549 time: OffsetDateTime,
550}
551
552impl TryFrom<Signature<'_>> for CommitUser {
553 type Error = anyhow::Error;
554
555 fn try_from(v: Signature<'_>) -> Result<Self> {
556 Ok(CommitUser {
557 name: String::from_utf8_lossy(v.name_bytes()).into_owned(),
558 email: String::from_utf8_lossy(v.email_bytes()).into_owned(),
559 time: OffsetDateTime::from_unix_timestamp(v.when().seconds())?,
560 })
561 }
562}
563
564impl CommitUser {
565 pub fn name(&self) -> &str {
566 &self.name
567 }
568
569 pub fn email(&self) -> &str {
570 &self.email
571 }
572
573 pub fn time(&self) -> OffsetDateTime {
574 self.time
575 }
576}
577
578#[derive(Debug)]
579pub struct Commit {
580 author: CommitUser,
581 committer: CommitUser,
582 oid: String,
583 tree: String,
584 parents: Vec<String>,
585 summary: String,
586 body: String,
587 pub diff_stats: String,
588 pub diff: String,
589 pub diff_plain: Bytes,
590}
591
592impl TryFrom<git2::Commit<'_>> for Commit {
593 type Error = anyhow::Error;
594
595 fn try_from(commit: git2::Commit<'_>) -> Result<Self> {
596 Ok(Commit {
597 author: CommitUser::try_from(commit.author())?,
598 committer: CommitUser::try_from(commit.committer())?,
599 oid: commit.id().to_string(),
600 tree: commit.tree_id().to_string(),
601 parents: commit.parent_ids().map(|v| v.to_string()).collect(),
602 summary: commit
603 .summary_bytes()
604 .map_or_else(|| Cow::Borrowed(""), String::from_utf8_lossy)
605 .into_owned(),
606 body: commit
607 .body_bytes()
608 .map_or_else(|| Cow::Borrowed(""), String::from_utf8_lossy)
609 .into_owned(),
610 diff_stats: String::with_capacity(0),
611 diff: String::with_capacity(0),
612 diff_plain: Bytes::new(),
613 })
614 }
615}
616
617impl Commit {
618 pub fn author(&self) -> &CommitUser {
619 &self.author
620 }
621
622 pub fn committer(&self) -> &CommitUser {
623 &self.committer
624 }
625
626 pub fn oid(&self) -> &str {
627 &self.oid
628 }
629
630 pub fn tree(&self) -> &str {
631 &self.tree
632 }
633
634 pub fn parents(&self) -> impl Iterator<Item = &str> {
635 self.parents.iter().map(String::as_str)
636 }
637
638 pub fn summary(&self) -> &str {
639 &self.summary
640 }
641
642 pub fn body(&self) -> &str {
643 &self.body
644 }
645}
646
647#[instrument(skip(repo, commit, syntax_set))]
648fn fetch_diff_and_stats(
649 repo: &git2::Repository,
650 commit: &git2::Commit<'_>,
651 syntax_set: &SyntaxSet,
652) -> Result<(Bytes, String, String)> {
653 let current_tree = commit.tree().context("Couldn't get tree for the commit")?;
654 let parent_tree = commit.parents().next().and_then(|v| v.tree().ok());
655 let mut diff_opts = DiffOptions::new();
656 let diff = repo.diff_tree_to_tree(
657 parent_tree.as_ref(),
658 Some(¤t_tree),
659 Some(&mut diff_opts),
660 )?;
661
662 let mut diff_plain = BytesMut::new();
663 let email = Email::from_diff(
664 &diff,
665 1,
666 1,
667 &commit.id(),
668 commit.summary().unwrap_or(""),
669 commit.body().unwrap_or(""),
670 &commit.author(),
671 &mut EmailCreateOptions::default(),
672 )
673 .context("Couldn't build diff for commit")?;
674 diff_plain.extend_from_slice(email.as_slice());
675
676 let diff_stats = diff
677 .stats()?
678 .to_buf(DiffStatsFormat::FULL, 80)?
679 .as_str()
680 .unwrap_or("")
681 .to_string();
682 let diff_output = format_diff(&diff, syntax_set)?;
683
684 Ok((diff_plain.freeze(), diff_output, diff_stats))
685}
686
687fn format_file(content: &[u8], extension: &str, syntax_set: &SyntaxSet) -> Result<String> {
688 let content = String::from_utf8_lossy(content);
689
690 let syntax = syntax_set
691 .find_syntax_by_extension(extension)
692 .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
693 let mut html_generator =
694 ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, ClassStyle::Spaced);
695
696 for line in LinesWithEndings::from(&content) {
697 html_generator
698 .parse_html_for_line_which_includes_newline(line)
699 .context("Couldn't parse line of file")?;
700 }
701
702 Ok(format!(
703 "<code>{}</code>",
704 html_generator.finalize().replace('\n', "</code>\n<code>")
705 ))
706}
707
708#[instrument(skip(diff, syntax_set))]
709fn format_diff(diff: &git2::Diff<'_>, syntax_set: &SyntaxSet) -> Result<String> {
710 let mut diff_output = String::new();
711
712 diff.print(DiffFormat::Patch, |delta, _diff_hunk, diff_line| {
713 let (class, should_highlight_as_source) = match diff_line.origin_value() {
714 DiffLineType::Addition => (Some("add-line"), true),
715 DiffLineType::Deletion => (Some("remove-line"), true),
716 DiffLineType::Context => (Some("context"), true),
717 DiffLineType::AddEOFNL => (Some("remove-line"), false),
718 DiffLineType::DeleteEOFNL => (Some("add-line"), false),
719 DiffLineType::FileHeader => (Some("file-header"), false),
720 _ => (None, false),
721 };
722
723 let line = String::from_utf8_lossy(diff_line.content());
724
725 let extension = if should_highlight_as_source {
726 if let Some(path) = delta.new_file().path() {
727 path.extension()
728 .or_else(|| path.file_name())
729 .map_or_else(|| Cow::Borrowed(""), OsStr::to_string_lossy)
730 } else {
731 Cow::Borrowed("")
732 }
733 } else {
734 Cow::Borrowed("patch")
735 };
736 let syntax = syntax_set
737 .find_syntax_by_extension(&extension)
738 .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
739 let mut html_generator =
740 ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, ClassStyle::Spaced);
741 let _res = html_generator.parse_html_for_line_which_includes_newline(&line);
742 if let Some(class) = class {
743 let _ = write!(diff_output, r#"<span class="diff-{class}">"#);
744 }
745 diff_output.push_str(&html_generator.finalize());
746 if class.is_some() {
747 diff_output.push_str("</span>");
748 }
749
750 true
751 })
752 .context("Failed to prepare diff")?;
753
754 Ok(diff_output)
755}