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::{Context, Result};
11use bytes::{Bytes, BytesMut};
12use comrak::{ComrakOptions, ComrakPlugins};
13use git2::{
14 BranchType, DiffFormat, DiffLineType, DiffOptions, DiffStatsFormat, Email, EmailCreateOptions,
15 ObjectType, Oid, Signature,
16};
17use moka::future::Cache;
18use parking_lot::Mutex;
19use syntect::{
20 html::{ClassStyle, ClassedHTMLGenerator},
21 parsing::SyntaxSet,
22 util::LinesWithEndings,
23};
24use time::OffsetDateTime;
25use tracing::instrument;
26
27use crate::syntax_highlight::ComrakSyntectAdapter;
28
29pub struct Git {
30 commits: Cache<Oid, Arc<Commit>>,
31 readme_cache: Cache<PathBuf, Option<(ReadmeFormat, Arc<str>)>>,
32 syntax_set: SyntaxSet,
33}
34
35impl Git {
36 #[instrument(skip(syntax_set))]
37 pub fn new(syntax_set: SyntaxSet) -> Self {
38 Self {
39 commits: Cache::builder()
40 .time_to_live(Duration::from_secs(10))
41 .max_capacity(100)
42 .build(),
43 readme_cache: Cache::builder()
44 .time_to_live(Duration::from_secs(10))
45 .max_capacity(100)
46 .build(),
47 syntax_set,
48 }
49 }
50}
51
52impl Git {
53 #[instrument(skip(self))]
54 pub async fn repo(self: Arc<Self>, repo_path: PathBuf) -> Result<Arc<OpenRepository>> {
55 let repo = tokio::task::spawn_blocking({
56 let repo_path = repo_path.clone();
57 move || git2::Repository::open(repo_path)
58 })
59 .await
60 .context("Failed to join Tokio task")?
61 .context("Failed to open repository")?;
62
63 Ok(Arc::new(OpenRepository {
64 git: self,
65 cache_key: repo_path,
66 repo: Mutex::new(repo),
67 }))
68 }
69}
70
71pub struct OpenRepository {
72 git: Arc<Git>,
73 cache_key: PathBuf,
74 repo: Mutex<git2::Repository>,
75}
76
77impl OpenRepository {
78 pub async fn path(
79 self: Arc<Self>,
80 path: Option<PathBuf>,
81 tree_id: Option<&str>,
82 branch: Option<String>,
83 ) -> Result<PathDestination> {
84 let tree_id = tree_id
85 .map(Oid::from_str)
86 .transpose()
87 .context("Failed to parse tree hash")?;
88
89 tokio::task::spawn_blocking(move || {
90 let repo = self.repo.lock();
91
92 let mut tree = if let Some(tree_id) = tree_id {
93 repo.find_tree(tree_id)
94 .context("Couldn't find tree with given id")?
95 } else if let Some(branch) = branch {
96 let branch = repo.find_branch(&branch, BranchType::Local)?;
97 branch
98 .get()
99 .peel_to_tree()
100 .context("Couldn't find tree for branch")?
101 } else {
102 let head = repo.head()?;
103 head.peel_to_tree()
104 .context("Couldn't find tree from HEAD")?
105 };
106
107 if let Some(path) = path.as_ref() {
108 let item = tree.get_path(path).context("Path doesn't exist in tree")?;
109 let object = item
110 .to_object(&repo)
111 .context("Path in tree isn't an object")?;
112
113 if let Some(blob) = object.as_blob() {
114 // TODO: use Path here instead of a lossy utf8 conv
115 let name = String::from_utf8_lossy(item.name_bytes());
116 let path = path.clone().join(&*name);
117
118 let extension = path
119 .extension()
120 .or_else(|| path.file_name())
121 .map_or_else(|| Cow::Borrowed(""), OsStr::to_string_lossy);
122 let content = format_file(blob.content(), &extension, &self.git.syntax_set)?;
123
124 return Ok(PathDestination::File(FileWithContent {
125 metadata: File {
126 mode: item.filemode(),
127 size: blob.size(),
128 path,
129 name: name.into_owned(),
130 },
131 content,
132 }));
133 } else if let Ok(new_tree) = object.into_tree() {
134 tree = new_tree;
135 } else {
136 anyhow::bail!("Given path not tree nor blob... what is it?!");
137 }
138 }
139
140 let mut tree_items = Vec::new();
141
142 for item in tree.iter() {
143 let object = item
144 .to_object(&repo)
145 .context("Expected item in tree to be object but it wasn't")?;
146
147 let name = String::from_utf8_lossy(item.name_bytes()).into_owned();
148 let path = path.clone().unwrap_or_default().join(&name);
149
150 if let Some(blob) = object.as_blob() {
151 tree_items.push(TreeItem::File(File {
152 mode: item.filemode(),
153 size: blob.size(),
154 path,
155 name,
156 }));
157 } else if let Some(_tree) = object.as_tree() {
158 tree_items.push(TreeItem::Tree(Tree {
159 mode: item.filemode(),
160 path,
161 name,
162 }));
163 }
164 }
165
166 Ok(PathDestination::Tree(tree_items))
167 })
168 .await
169 .context("Failed to join Tokio task")?
170 }
171
172 #[instrument(skip(self))]
173 pub async fn tag_info(self: Arc<Self>, tag_name: &str) -> Result<DetailedTag> {
174 let reference = format!("refs/tags/{tag_name}");
175 let tag_name = tag_name.to_string();
176
177 tokio::task::spawn_blocking(move || {
178 let repo = self.repo.lock();
179
180 let tag = repo
181 .find_reference(&reference)
182 .context("Given reference does not exist in repository")?
183 .peel_to_tag()
184 .context("Couldn't get to a tag from the given reference")?;
185 let tag_target = tag.target().context("Couldn't find tagged object")?;
186
187 let tagged_object = match tag_target.kind() {
188 Some(ObjectType::Commit) => Some(TaggedObject::Commit(tag_target.id().to_string())),
189 Some(ObjectType::Tree) => Some(TaggedObject::Tree(tag_target.id().to_string())),
190 None | Some(_) => None,
191 };
192
193 Ok(DetailedTag {
194 name: tag_name,
195 tagger: tag.tagger().map(TryInto::try_into).transpose()?,
196 message: tag
197 .message_bytes()
198 .map_or_else(|| Cow::Borrowed(""), String::from_utf8_lossy)
199 .into_owned(),
200 tagged_object,
201 })
202 })
203 .await
204 .context("Failed to join Tokio task")?
205 }
206
207 #[instrument(skip(self))]
208 pub async fn readme(
209 self: Arc<Self>,
210 ) -> Result<Option<(ReadmeFormat, Arc<str>)>, Arc<anyhow::Error>> {
211 const README_FILES: &[&str] = &["README.md", "README", "README.txt"];
212
213 let git = self.git.clone();
214
215 git.readme_cache
216 .try_get_with(self.cache_key.clone(), async move {
217 tokio::task::spawn_blocking(move || {
218 let repo = self.repo.lock();
219
220 let head = repo.head().context("Couldn't find HEAD of repository")?;
221 let commit = head.peel_to_commit().context(
222 "Couldn't find the commit that the HEAD of the repository refers to",
223 )?;
224 let tree = commit
225 .tree()
226 .context("Couldn't get the tree that the HEAD refers to")?;
227
228 for name in README_FILES {
229 let Some(tree_entry) = tree.get_name(name) else {
230 continue;
231 };
232
233 let Some(blob) = tree_entry
234 .to_object(&repo)
235 .ok()
236 .and_then(|v| v.into_blob().ok())
237 else {
238 continue;
239 };
240
241 let Ok(content) = std::str::from_utf8(blob.content()) else {
242 continue;
243 };
244
245 if Path::new(name).extension().and_then(OsStr::to_str) == Some("md") {
246 let value = parse_and_transform_markdown(content, &self.git.syntax_set);
247 return Ok(Some((ReadmeFormat::Markdown, Arc::from(value))));
248 }
249
250 return Ok(Some((ReadmeFormat::Plaintext, Arc::from(content))));
251 }
252
253 Ok(None)
254 })
255 .await
256 .context("Failed to join Tokio task")?
257 })
258 .await
259 }
260
261 #[instrument(skip(self))]
262 pub async fn latest_commit(self: Arc<Self>) -> Result<Commit> {
263 tokio::task::spawn_blocking(move || {
264 let repo = self.repo.lock();
265
266 let head = repo.head().context("Couldn't find HEAD of repository")?;
267 let commit = head
268 .peel_to_commit()
269 .context("Couldn't find commit HEAD of repository refers to")?;
270 let (diff_plain, diff_output, diff_stats) =
271 fetch_diff_and_stats(&repo, &commit, &self.git.syntax_set)?;
272
273 let mut commit = Commit::try_from(commit)?;
274 commit.diff_stats = diff_stats;
275 commit.diff = diff_output;
276 commit.diff_plain = diff_plain;
277 Ok(commit)
278 })
279 .await
280 .context("Failed to join Tokio task")?
281 }
282
283 #[instrument(skip(self))]
284 pub async fn commit(self: Arc<Self>, commit: &str) -> Result<Arc<Commit>, Arc<anyhow::Error>> {
285 let commit = Oid::from_str(commit)
286 .map_err(anyhow::Error::from)
287 .map_err(Arc::new)?;
288
289 let git = self.git.clone();
290
291 git.commits
292 .try_get_with(commit, async move {
293 tokio::task::spawn_blocking(move || {
294 let repo = self.repo.lock();
295
296 let commit = repo.find_commit(commit)?;
297 let (diff_plain, diff_output, diff_stats) =
298 fetch_diff_and_stats(&repo, &commit, &self.git.syntax_set)?;
299
300 let mut commit = Commit::try_from(commit)?;
301 commit.diff_stats = diff_stats;
302 commit.diff = diff_output;
303 commit.diff_plain = diff_plain;
304
305 Ok(Arc::new(commit))
306 })
307 .await
308 .context("Failed to join Tokio task")?
309 })
310 .await
311 }
312}
313
314fn parse_and_transform_markdown(s: &str, syntax_set: &SyntaxSet) -> String {
315 let mut plugins = ComrakPlugins::default();
316
317 let highlighter = ComrakSyntectAdapter { syntax_set };
318 plugins.render.codefence_syntax_highlighter = Some(&highlighter);
319
320 comrak::markdown_to_html_with_plugins(s, &ComrakOptions::default(), &plugins)
321}
322
323#[derive(Copy, Clone, Debug, PartialEq, Eq)]
324pub enum ReadmeFormat {
325 Markdown,
326 Plaintext,
327}
328
329pub enum PathDestination {
330 Tree(Vec<TreeItem>),
331 File(FileWithContent),
332}
333
334pub enum TreeItem {
335 Tree(Tree),
336 File(File),
337}
338
339#[derive(Debug)]
340pub struct Tree {
341 pub mode: i32,
342 pub name: String,
343 pub path: PathBuf,
344}
345
346#[derive(Debug)]
347pub struct File {
348 pub mode: i32,
349 pub size: usize,
350 pub name: String,
351 pub path: PathBuf,
352}
353
354#[derive(Debug)]
355pub struct FileWithContent {
356 pub metadata: File,
357 pub content: String,
358}
359
360#[derive(Debug)]
361pub struct Branch {
362 pub name: String,
363 pub commit: Commit,
364}
365
366#[derive(Debug)]
367pub struct Remote {
368 pub name: String,
369}
370
371#[derive(Debug)]
372pub enum TaggedObject {
373 Commit(String),
374 Tree(String),
375}
376
377#[derive(Debug)]
378pub struct DetailedTag {
379 pub name: String,
380 pub tagger: Option<CommitUser>,
381 pub message: String,
382 pub tagged_object: Option<TaggedObject>,
383}
384
385#[derive(Debug)]
386pub struct CommitUser {
387 name: String,
388 email: String,
389 time: OffsetDateTime,
390}
391
392impl TryFrom<Signature<'_>> for CommitUser {
393 type Error = anyhow::Error;
394
395 fn try_from(v: Signature<'_>) -> Result<Self> {
396 Ok(CommitUser {
397 name: String::from_utf8_lossy(v.name_bytes()).into_owned(),
398 email: String::from_utf8_lossy(v.email_bytes()).into_owned(),
399 time: OffsetDateTime::from_unix_timestamp(v.when().seconds())?,
400 })
401 }
402}
403
404impl CommitUser {
405 pub fn name(&self) -> &str {
406 &self.name
407 }
408
409 pub fn email(&self) -> &str {
410 &self.email
411 }
412
413 pub fn time(&self) -> OffsetDateTime {
414 self.time
415 }
416}
417
418#[derive(Debug)]
419pub struct Commit {
420 author: CommitUser,
421 committer: CommitUser,
422 oid: String,
423 tree: String,
424 parents: Vec<String>,
425 summary: String,
426 body: String,
427 pub diff_stats: String,
428 pub diff: String,
429 pub diff_plain: Bytes,
430}
431
432impl TryFrom<git2::Commit<'_>> for Commit {
433 type Error = anyhow::Error;
434
435 fn try_from(commit: git2::Commit<'_>) -> Result<Self> {
436 Ok(Commit {
437 author: CommitUser::try_from(commit.author())?,
438 committer: CommitUser::try_from(commit.committer())?,
439 oid: commit.id().to_string(),
440 tree: commit.tree_id().to_string(),
441 parents: commit.parent_ids().map(|v| v.to_string()).collect(),
442 summary: commit
443 .summary_bytes()
444 .map_or_else(|| Cow::Borrowed(""), String::from_utf8_lossy)
445 .into_owned(),
446 body: commit
447 .body_bytes()
448 .map_or_else(|| Cow::Borrowed(""), String::from_utf8_lossy)
449 .into_owned(),
450 diff_stats: String::with_capacity(0),
451 diff: String::with_capacity(0),
452 diff_plain: Bytes::new(),
453 })
454 }
455}
456
457impl Commit {
458 pub fn author(&self) -> &CommitUser {
459 &self.author
460 }
461
462 pub fn committer(&self) -> &CommitUser {
463 &self.committer
464 }
465
466 pub fn oid(&self) -> &str {
467 &self.oid
468 }
469
470 pub fn tree(&self) -> &str {
471 &self.tree
472 }
473
474 pub fn parents(&self) -> impl Iterator<Item = &str> {
475 self.parents.iter().map(String::as_str)
476 }
477
478 pub fn summary(&self) -> &str {
479 &self.summary
480 }
481
482 pub fn body(&self) -> &str {
483 &self.body
484 }
485}
486
487#[instrument(skip(repo, commit, syntax_set))]
488fn fetch_diff_and_stats(
489 repo: &git2::Repository,
490 commit: &git2::Commit<'_>,
491 syntax_set: &SyntaxSet,
492) -> Result<(Bytes, String, String)> {
493 let current_tree = commit.tree().context("Couldn't get tree for the commit")?;
494 let parent_tree = commit.parents().next().and_then(|v| v.tree().ok());
495 let mut diff_opts = DiffOptions::new();
496 let diff = repo.diff_tree_to_tree(
497 parent_tree.as_ref(),
498 Some(¤t_tree),
499 Some(&mut diff_opts),
500 )?;
501
502 let mut diff_plain = BytesMut::new();
503 let email = Email::from_diff(
504 &diff,
505 1,
506 1,
507 &commit.id(),
508 "",
509 "",
510 &commit.author(),
511 &mut EmailCreateOptions::default(),
512 )
513 .context("Couldn't build diff for commit")?;
514 diff_plain.extend_from_slice(email.as_slice());
515
516 let diff_stats = diff
517 .stats()?
518 .to_buf(DiffStatsFormat::FULL, 80)?
519 .as_str()
520 .unwrap_or("")
521 .to_string();
522 let diff_output = format_diff(&diff, syntax_set)?;
523
524 Ok((diff_plain.freeze(), diff_output, diff_stats))
525}
526
527fn format_file(content: &[u8], extension: &str, syntax_set: &SyntaxSet) -> Result<String> {
528 let content = String::from_utf8_lossy(content);
529
530 let syntax = syntax_set
531 .find_syntax_by_extension(extension)
532 .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
533 let mut html_generator =
534 ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, ClassStyle::Spaced);
535
536 for line in LinesWithEndings::from(&content) {
537 html_generator
538 .parse_html_for_line_which_includes_newline(line)
539 .context("Couldn't parse line of file")?;
540 }
541
542 Ok(format!(
543 "<code>{}</code>",
544 html_generator.finalize().replace('\n', "</code>\n<code>")
545 ))
546}
547
548#[instrument(skip(diff, syntax_set))]
549fn format_diff(diff: &git2::Diff<'_>, syntax_set: &SyntaxSet) -> Result<String> {
550 let mut diff_output = String::new();
551
552 diff.print(DiffFormat::Patch, |delta, _diff_hunk, diff_line| {
553 let (class, should_highlight_as_source) = match diff_line.origin_value() {
554 DiffLineType::Addition => (Some("add-line"), true),
555 DiffLineType::Deletion => (Some("remove-line"), true),
556 DiffLineType::Context => (Some("context"), true),
557 DiffLineType::AddEOFNL => (Some("remove-line"), false),
558 DiffLineType::DeleteEOFNL => (Some("add-line"), false),
559 DiffLineType::FileHeader => (Some("file-header"), false),
560 _ => (None, false),
561 };
562
563 let line = String::from_utf8_lossy(diff_line.content());
564
565 let extension = if should_highlight_as_source {
566 if let Some(path) = delta.new_file().path() {
567 path.extension()
568 .or_else(|| path.file_name())
569 .map_or_else(|| Cow::Borrowed(""), OsStr::to_string_lossy)
570 } else {
571 Cow::Borrowed("")
572 }
573 } else {
574 Cow::Borrowed("patch")
575 };
576 let syntax = syntax_set
577 .find_syntax_by_extension(&extension)
578 .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
579 let mut html_generator =
580 ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, ClassStyle::Spaced);
581 let _res = html_generator.parse_html_for_line_which_includes_newline(&line);
582 if let Some(class) = class {
583 let _ = write!(diff_output, r#"<span class="diff-{class}">"#);
584 }
585 diff_output.push_str(&html_generator.finalize());
586 if class.is_some() {
587 diff_output.push_str("</span>");
588 }
589
590 true
591 })
592 .context("Failed to prepare diff")?;
593
594 Ok(diff_output)
595}