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