gitscope
← rgit / 0.1.2 / 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        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(&current_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}