gitscope
← rgit / 0.1.1 / src/git.rs
https://dev.kronoz.win/r/rgit.git
src/git.rs · Rust
Blame Raw
  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 {
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(&current_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}