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