gitscope
← rgit / main / src/git.rs
https://dev.kronoz.win/r/rgit.git
src/git.rs · Rust
Blame Raw
   1use anyhow::{Context, Result, anyhow};
   2use axum::response::IntoResponse;
   3use bytes::{BufMut, Bytes, BytesMut, buf::Writer};
   4use comrak::{ComrakPlugins, Options};
   5use flate2::write::GzEncoder;
   6use gix::{
   7    ObjectId, ThreadSafeRepository,
   8    actor::SignatureRef,
   9    bstr::{BStr, BString, ByteSlice, ByteVec},
  10    diff::blob::{Sink, platform::prepare_diff::Operation},
  11    object::Kind,
  12    objs::{CommitRef, TagRef, tree::EntryRef},
  13    prelude::TreeEntryRefExt,
  14    traverse::tree::visit::Action,
  15};
  16use itertools::Either;
  17use moka::future::Cache;
  18use std::{
  19    borrow::Cow,
  20    collections::VecDeque,
  21    ffi::OsStr,
  22    fmt::{self, Arguments, Write},
  23    io::ErrorKind,
  24    iter::Copied,
  25    path::{Path, PathBuf},
  26    str::FromStr,
  27    sync::Arc,
  28    time::Duration,
  29};
  30use tar::Builder;
  31use time::{OffsetDateTime, UtcOffset};
  32use tracing::{error, instrument, warn};
  33use yoke::{Yoke, Yokeable};
  34
  35use crate::{
  36    methods::filters::DisplayHexBuffer,
  37    syntax_highlight::{ComrakHighlightAdapter, FileIdentifier, format_file, format_file_inner},
  38    unified_diff_builder::{Callback, UnifiedDiffBuilder},
  39};
  40
  41type ReadmeCacheKey = (PathBuf, Option<Arc<str>>);
  42
  43pub struct Git {
  44    commits: Cache<(ObjectId, bool), Arc<Commit>, hashbrown::hash_map::DefaultHashBuilder>,
  45    readme_cache: Cache<
  46        ReadmeCacheKey,
  47        Option<(ReadmeFormat, Arc<str>)>,
  48        hashbrown::hash_map::DefaultHashBuilder,
  49    >,
  50    open_repositories:
  51        Cache<PathBuf, ThreadSafeRepository, hashbrown::hash_map::DefaultHashBuilder>,
  52}
  53
  54impl Git {
  55    #[instrument]
  56    pub fn new() -> Self {
  57        Self {
  58            commits: Cache::builder()
  59                .time_to_live(Duration::from_secs(30))
  60                .max_capacity(100)
  61                .build_with_hasher(hashbrown::hash_map::DefaultHashBuilder::default()),
  62            readme_cache: Cache::builder()
  63                .time_to_live(Duration::from_secs(30))
  64                .max_capacity(100)
  65                .build_with_hasher(hashbrown::hash_map::DefaultHashBuilder::default()),
  66            open_repositories: Cache::builder()
  67                .time_to_idle(Duration::from_secs(120))
  68                .max_capacity(100)
  69                .build_with_hasher(hashbrown::hash_map::DefaultHashBuilder::default()),
  70        }
  71    }
  72}
  73
  74impl Git {
  75    #[instrument(skip(self))]
  76    pub async fn repo(
  77        self: Arc<Self>,
  78        repo_path: PathBuf,
  79        branch: Option<Arc<str>>,
  80    ) -> Result<Arc<OpenRepository>> {
  81        let repo = repo_path.clone();
  82        let repo = self
  83            .open_repositories
  84            .try_get_with_by_ref(&repo_path, async move {
  85                tokio::task::spawn_blocking(move || {
  86                    gix::open::Options::isolated()
  87                        .open_path_as_is(true)
  88                        .open(&repo)
  89                })
  90                .await
  91                .context("Failed to join Tokio task")
  92                .map_err(|e| std::io::Error::new(ErrorKind::Other, e))?
  93                .map_err(|err| {
  94                    error!("{}", err);
  95                    std::io::Error::new(ErrorKind::Other, "Failed to open repository")
  96                })
  97            })
  98            .await?;
  99
 100        Ok(Arc::new(OpenRepository {
 101            git: self,
 102            cache_key: repo_path,
 103            repo,
 104            branch,
 105        }))
 106    }
 107}
 108
 109pub struct OpenRepository {
 110    git: Arc<Git>,
 111    cache_key: PathBuf,
 112    repo: ThreadSafeRepository,
 113    branch: Option<Arc<str>>,
 114}
 115
 116impl OpenRepository {
 117    #[allow(clippy::too_many_lines)]
 118    pub async fn path(
 119        self: Arc<Self>,
 120        path: Option<PathBuf>,
 121        tree_id: Option<&str>,
 122        formatted: bool,
 123    ) -> Result<FileWithContent> {
 124        let tree_id = tree_id
 125            .map(ObjectId::from_str)
 126            .transpose()
 127            .context("Failed to parse tree hash")?;
 128
 129        tokio::task::spawn_blocking(move || {
 130            let repo = self.repo.to_thread_local();
 131
 132            let mut tree = if let Some(tree_id) = tree_id {
 133                repo.find_tree(tree_id)
 134                    .context("Couldn't find tree with given id")?
 135            } else if let Some(branch) = &self.branch {
 136                repo.find_reference(branch.as_ref())?
 137                    .peel_to_tree()
 138                    .context("Couldn't find tree for reference")?
 139            } else {
 140                repo.find_reference("HEAD")
 141                    .context("Failed to find HEAD")?
 142                    .peel_to_tree()
 143                    .context("Couldn't find HEAD for reference")?
 144            };
 145
 146            if let Some(path) = path.as_ref() {
 147                let item = tree
 148                    .peel_to_entry_by_path(path)?
 149                    .context("Path doesn't exist in tree")?;
 150                let object = item.object().context("Path in tree isn't an object")?;
 151
 152                match object.kind {
 153                    Kind::Blob => {
 154                        let mut blob = object.into_blob();
 155
 156                        let content = match (formatted, simdutf8::basic::from_utf8(&blob.data)) {
 157                            (true, Err(_)) => Content::Binary(vec![]),
 158                            (true, Ok(data)) => Content::Text(Cow::Owned(format_file(
 159                                data,
 160                                FileIdentifier::Path(path.as_path()),
 161                            )?)),
 162                            (false, Err(_)) => Content::Binary(blob.take_data()),
 163                            (false, Ok(_data)) => Content::Text(Cow::Owned(unsafe {
 164                                String::from_utf8_unchecked(blob.take_data())
 165                            })),
 166                        };
 167
 168                        return Ok(FileWithContent { content });
 169                    }
 170                    _ => anyhow::bail!("bad object of type {:?}", object.kind),
 171                }
 172            }
 173
 174            anyhow::bail!("bad object");
 175        })
 176        .await
 177        .context("Failed to join Tokio task")?
 178    }
 179
 180    #[instrument(skip(self))]
 181    pub async fn tag_info(self: Arc<Self>) -> Result<Yoke<DetailedTag<'static>, Vec<u8>>> {
 182        tokio::task::spawn_blocking(move || {
 183            let tag_name = self.branch.clone().context("no tag given")?;
 184            let repo = self.repo.to_thread_local();
 185
 186            let tag = repo
 187                .find_reference(&format!("refs/tags/{tag_name}"))
 188                .context("Given tag does not exist in repository")?
 189                .peel_to_tag()
 190                .context("Couldn't get to a tag from the given reference")?
 191                .detach()
 192                .data;
 193
 194            Yoke::try_attach_to_cart(tag, move |tag| {
 195                let tag = TagRef::from_bytes(tag)?;
 196
 197                let tagged_object = match tag.target_kind {
 198                    Kind::Commit => Some(TaggedObject::Commit(tag.target)),
 199                    Kind::Tree => Some(TaggedObject::Tree(tag.target)),
 200                    _ => None,
 201                };
 202
 203                Ok::<_, anyhow::Error>(DetailedTag {
 204                    name: tag_name,
 205                    tagger: tag.tagger.map(TryInto::try_into).transpose()?,
 206                    tagged_object,
 207                    message: tag.message,
 208                })
 209            })
 210        })
 211        .await
 212        .context("Failed to join Tokio task")?
 213    }
 214
 215    #[instrument(skip(self))]
 216    pub async fn readme(
 217        self: Arc<Self>,
 218    ) -> Result<Option<(ReadmeFormat, Arc<str>)>, Arc<anyhow::Error>> {
 219        const README_FILES: &[&str] = &["README.md", "README", "README.txt"];
 220
 221        let git = self.git.clone();
 222
 223        git.readme_cache
 224            .try_get_with((self.cache_key.clone(), self.branch.clone()), async move {
 225                tokio::task::spawn_blocking(move || {
 226                    let repo = self.repo.to_thread_local();
 227
 228                    let mut head = if let Some(reference) = &self.branch {
 229                        repo.find_reference(reference.as_ref())?
 230                    } else {
 231                        repo.find_reference("HEAD")
 232                            .context("Couldn't find HEAD of repository")?
 233                    };
 234
 235                    let commit = head.peel_to_commit().context(
 236                        "Couldn't find the commit that the HEAD of the repository refers to",
 237                    )?;
 238                    let mut tree = commit
 239                        .tree()
 240                        .context("Couldn't get the tree that the HEAD refers to")?;
 241
 242                    for name in README_FILES {
 243                        let Some(tree_entry) = tree.peel_to_entry_by_path(name)? else {
 244                            continue;
 245                        };
 246
 247                        let Some(blob) = tree_entry
 248                            .object()
 249                            .ok()
 250                            .and_then(|v| v.try_into_blob().ok())
 251                        else {
 252                            continue;
 253                        };
 254
 255                        let Ok(content) = simdutf8::basic::from_utf8(&blob.data) else {
 256                            continue;
 257                        };
 258
 259                        if Path::new(name).extension().and_then(OsStr::to_str) == Some("md") {
 260                            let value = parse_and_transform_markdown(content);
 261                            return Ok(Some((ReadmeFormat::Markdown, Arc::from(value))));
 262                        }
 263
 264                        return Ok(Some((ReadmeFormat::Plaintext, Arc::from(content))));
 265                    }
 266
 267                    Ok(None)
 268                })
 269                .await
 270                .context("Failed to join Tokio task")?
 271            })
 272            .await
 273    }
 274
 275    pub async fn default_branch(self: Arc<Self>) -> Result<Option<String>> {
 276        tokio::task::spawn_blocking(move || {
 277            let repo = self.repo.to_thread_local();
 278            let head = repo.head().context("Couldn't find HEAD of repository")?;
 279            Ok(head.referent_name().map(|v| v.shorten().to_string()))
 280        })
 281        .await
 282        .context("Failed to join Tokio task")?
 283    }
 284
 285    #[instrument(skip(self))]
 286    pub async fn latest_commit(self: Arc<Self>, highlighted: bool) -> Result<Commit> {
 287        tokio::task::spawn_blocking(move || {
 288            let repo = self.repo.to_thread_local();
 289
 290            let mut head = if let Some(reference) = &self.branch {
 291                repo.find_reference(reference.as_ref())?
 292            } else {
 293                repo.find_reference("HEAD")
 294                    .context("Couldn't find HEAD of repository")?
 295            };
 296
 297            let commit = head
 298                .peel_to_commit()
 299                .context("Couldn't find commit HEAD of repository refers to")?;
 300            let (diff_output, diff_stats) = fetch_diff_and_stats(&repo, &commit, highlighted)?;
 301
 302            let oid = take_oid(commit.id);
 303            let inner = Yoke::try_attach_to_cart(commit.detach().data, |commit| {
 304                CommitInner::new(CommitRef::from_bytes(commit)?, oid)
 305            })?;
 306
 307            Ok(Commit {
 308                inner,
 309                diff_stats,
 310                diff: diff_output,
 311            })
 312        })
 313        .await
 314        .context("Failed to join Tokio task")?
 315    }
 316
 317    #[instrument(skip_all)]
 318    pub async fn archive(
 319        self: Arc<Self>,
 320        res: tokio::sync::mpsc::Sender<Result<Bytes, anyhow::Error>>,
 321        cont: tokio::sync::oneshot::Sender<()>,
 322        commit: Option<&str>,
 323    ) -> Result<(), anyhow::Error> {
 324        let commit = commit
 325            .map(ObjectId::from_str)
 326            .transpose()
 327            .context("failed to build oid")?;
 328
 329        tokio::task::spawn_blocking(move || {
 330            let repo = self.repo.to_thread_local();
 331
 332            let tree = if let Some(commit) = commit {
 333                repo.find_commit(commit)?.tree()?
 334            } else if let Some(reference) = &self.branch {
 335                repo.find_reference(reference.as_ref())?.peel_to_tree()?
 336            } else {
 337                repo.find_reference("HEAD")
 338                    .context("Couldn't find HEAD of repository")?
 339                    .peel_to_tree()?
 340            };
 341
 342            // tell the web server it can send response headers to the requester
 343            if cont.send(()).is_err() {
 344                return Err(anyhow!("requester gone"));
 345            }
 346
 347            let buffer = BytesMut::with_capacity(BUFFER_CAP + 1024);
 348            let mut visitor = PathVisitor::new(ArchivalVisitor {
 349                repository: &repo,
 350                res,
 351                archive: Builder::new(GzEncoder::new(buffer.writer(), flate2::Compression::fast())),
 352            });
 353
 354            tree.traverse().breadthfirst(&mut visitor)?;
 355
 356            let visitor = visitor.into_inner();
 357
 358            visitor.res.blocking_send(Ok(visitor
 359                .archive
 360                .into_inner()?
 361                .finish()?
 362                .into_inner()
 363                .freeze()))?;
 364
 365            Ok::<_, anyhow::Error>(())
 366        })
 367        .await??;
 368
 369        Ok(())
 370    }
 371
 372    #[instrument(skip(self))]
 373    pub async fn commit(
 374        self: Arc<Self>,
 375        commit: &str,
 376        highlighted: bool,
 377    ) -> Result<Arc<Commit>, Arc<anyhow::Error>> {
 378        let commit = ObjectId::from_str(commit)
 379            .map_err(anyhow::Error::from)
 380            .map_err(Arc::new)?;
 381
 382        let git = self.git.clone();
 383
 384        git.commits
 385            .try_get_with((commit, highlighted), async move {
 386                tokio::task::spawn_blocking(move || {
 387                    let repo = self.repo.to_thread_local();
 388
 389                    let commit = repo.find_commit(commit)?;
 390
 391                    let (diff_output, diff_stats) =
 392                        fetch_diff_and_stats(&repo, &commit, highlighted)?;
 393
 394                    let oid = take_oid(commit.id);
 395
 396                    let inner = Yoke::try_attach_to_cart(commit.detach().data, |commit| {
 397                        CommitInner::new(CommitRef::from_bytes(commit)?, oid)
 398                    })?;
 399
 400                    Ok(Arc::new(Commit {
 401                        inner,
 402                        diff_stats,
 403                        diff: diff_output,
 404                    }))
 405                })
 406                .await
 407                .context("Failed to join Tokio task")?
 408            })
 409            .await
 410    }
 411}
 412
 413fn take_oid(v: ObjectId) -> [u8; 20] {
 414    match v {
 415        ObjectId::Sha1(v) => v,
 416    }
 417}
 418
 419const BUFFER_CAP: usize = 512 * 1024;
 420
 421pub trait PathVisitorHandler {
 422    fn visit(&mut self, entry: &EntryRef<'_>, path: &BStr) -> Action;
 423}
 424
 425struct ArchivalVisitor<'a> {
 426    repository: &'a gix::Repository,
 427    res: tokio::sync::mpsc::Sender<Result<Bytes, anyhow::Error>>,
 428    archive: Builder<GzEncoder<Writer<BytesMut>>>,
 429}
 430
 431impl PathVisitorHandler for ArchivalVisitor<'_> {
 432    fn visit(&mut self, entry: &EntryRef<'_>, path: &BStr) -> Action {
 433        let entry = entry.attach(self.repository);
 434
 435        let Ok(object) = entry.object() else {
 436            return Action::Continue;
 437        };
 438
 439        if object.kind != Kind::Blob {
 440            return Action::Continue;
 441        }
 442
 443        let blob = object.into_blob();
 444
 445        let mut header = tar::Header::new_gnu();
 446        if let Err(error) = header.set_path(path.to_path_lossy()) {
 447            warn!(%error, "Attempted to write invalid path to archive");
 448            return Action::Continue;
 449        }
 450        header.set_size(blob.data.len() as u64);
 451        #[allow(clippy::cast_sign_loss)]
 452        header.set_mode(entry.mode().0.into());
 453        header.set_cksum();
 454
 455        if let Err(error) = self.archive.append(&header, blob.data.as_slice()) {
 456            warn!(%error, "Failed to append to archive");
 457            return Action::Cancel;
 458        }
 459
 460        if self.archive.get_ref().get_ref().get_ref().len() >= BUFFER_CAP {
 461            let b = self.archive.get_mut().get_mut().get_mut().split().freeze();
 462
 463            if self.res.blocking_send(Ok(b)).is_err() {
 464                return Action::Cancel;
 465            }
 466        }
 467
 468        Action::Continue
 469    }
 470}
 471
 472pub struct PathVisitor<T> {
 473    path_deque: VecDeque<BString>,
 474    path: BString,
 475    inner: T,
 476}
 477
 478impl<T> PathVisitor<T> {
 479    pub fn new(inner: T) -> Self {
 480        Self {
 481            path_deque: VecDeque::new(),
 482            path: BString::default(),
 483            inner,
 484        }
 485    }
 486
 487    pub fn into_inner(self) -> T {
 488        self.inner
 489    }
 490
 491    fn pop_element(&mut self) {
 492        if let Some(pos) = memchr::memrchr(b'/', &self.path) {
 493            self.path.resize(pos, 0);
 494        } else {
 495            self.path.clear();
 496        }
 497    }
 498
 499    fn push_element(&mut self, name: &BStr) {
 500        if name.is_empty() {
 501            return;
 502        }
 503        if !self.path.is_empty() {
 504            self.path.push(b'/');
 505        }
 506        self.path.push_str(name);
 507    }
 508}
 509
 510impl<T: PathVisitorHandler> gix::traverse::tree::Visit for PathVisitor<T> {
 511    fn pop_front_tracked_path_and_set_current(&mut self) {
 512        self.path = self
 513            .path_deque
 514            .pop_front()
 515            .expect("every call is matched with push_tracked_path_component");
 516    }
 517
 518    fn pop_back_tracked_path_and_set_current(&mut self) {
 519        self.path = self.path_deque.pop_back().unwrap_or_default();
 520    }
 521
 522    fn push_back_tracked_path_component(&mut self, component: &BStr) {
 523        self.push_element(component);
 524        self.path_deque.push_back(self.path.clone());
 525    }
 526
 527    fn push_path_component(&mut self, component: &BStr) {
 528        self.push_element(component);
 529    }
 530
 531    fn pop_path_component(&mut self) {
 532        self.pop_element();
 533    }
 534
 535    fn visit_tree(&mut self, entry: &EntryRef<'_>) -> Action {
 536        self.inner.visit(entry, self.path.as_ref())
 537    }
 538
 539    fn visit_nontree(&mut self, entry: &EntryRef<'_>) -> Action {
 540        self.inner.visit(entry, self.path.as_ref())
 541    }
 542}
 543
 544fn parse_and_transform_markdown(s: &str) -> String {
 545    let mut plugins = ComrakPlugins::default();
 546
 547    plugins.render.codefence_syntax_highlighter = Some(&ComrakHighlightAdapter);
 548
 549    // enable gfm extensions
 550    // https://github.github.com/gfm/
 551    let mut options = Options::default();
 552    options.extension.autolink = true;
 553    options.extension.footnotes = true;
 554    options.extension.strikethrough = true;
 555    options.extension.table = true;
 556    options.extension.tagfilter = true;
 557    options.extension.tasklist = true;
 558
 559    comrak::markdown_to_html_with_plugins(s, &options, &plugins)
 560}
 561
 562#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 563pub enum ReadmeFormat {
 564    Markdown,
 565    Plaintext,
 566}
 567
 568#[derive(Debug)]
 569#[allow(unused)]
 570pub struct FileWithContent {
 571    pub content: Content,
 572}
 573
 574#[derive(Debug)]
 575pub enum Content {
 576    Text(Cow<'static, str>),
 577    Binary(Vec<u8>),
 578}
 579
 580impl IntoResponse for Content {
 581    fn into_response(self) -> axum::response::Response {
 582        use axum::http;
 583
 584        match self {
 585            Self::Text(t) => {
 586                let headers = [(
 587                    http::header::CONTENT_TYPE,
 588                    http::HeaderValue::from_static("text/plain; charset=UTF-8"),
 589                )];
 590
 591                (headers, t).into_response()
 592            }
 593            Self::Binary(b) => {
 594                let headers = [(
 595                    http::header::CONTENT_TYPE,
 596                    http::HeaderValue::from_static("application/octet-stream"),
 597                )];
 598
 599                (headers, b).into_response()
 600            }
 601        }
 602    }
 603}
 604
 605#[derive(Debug)]
 606pub enum TaggedObject<'a> {
 607    Commit(&'a BStr),
 608    Tree(&'a BStr),
 609}
 610
 611#[derive(Debug, Yokeable)]
 612pub struct DetailedTag<'a> {
 613    pub name: Arc<str>,
 614    pub tagger: Option<CommitUser<'a>>,
 615    pub message: &'a BStr,
 616    pub tagged_object: Option<TaggedObject<'a>>,
 617}
 618
 619#[derive(Debug)]
 620pub struct CommitUser<'a> {
 621    name: &'a BStr,
 622    email: &'a BStr,
 623    time: (i64, i32),
 624}
 625
 626impl<'a> TryFrom<SignatureRef<'a>> for CommitUser<'a> {
 627    type Error = anyhow::Error;
 628
 629    fn try_from(v: SignatureRef<'a>) -> Result<Self> {
 630        Ok(CommitUser {
 631            name: v.name,
 632            email: v.email,
 633            time: (v.time.seconds, v.time.offset),
 634            // time: OffsetDateTime::from_unix_timestamp(v.time.seconds)?
 635            //     .to_offset(UtcOffset::from_whole_seconds(v.time.offset)?),
 636        })
 637    }
 638}
 639
 640impl CommitUser<'_> {
 641    pub fn name(&self) -> &BStr {
 642        self.name
 643    }
 644
 645    pub fn email(&self) -> &BStr {
 646        self.email
 647    }
 648
 649    pub fn time(&self) -> OffsetDateTime {
 650        OffsetDateTime::from_unix_timestamp(self.time.0)
 651            .unwrap()
 652            .to_offset(UtcOffset::from_whole_seconds(self.time.1).unwrap())
 653    }
 654}
 655
 656#[derive(Debug)]
 657pub struct Commit {
 658    inner: yoke::Yoke<CommitInner<'static>, Vec<u8>>,
 659    pub diff_stats: String,
 660    pub diff: String,
 661}
 662
 663impl Commit {
 664    pub fn get(&self) -> &CommitInner<'_> {
 665        self.inner.get()
 666    }
 667}
 668
 669#[derive(Debug, Yokeable)]
 670pub struct CommitInner<'a> {
 671    author: CommitUser<'a>,
 672    committer: CommitUser<'a>,
 673    oid: [u8; 20],
 674    tree: &'a BStr,
 675    parents: SmallVec<&'a BStr>,
 676    summary: Cow<'a, BStr>,
 677    body: &'a BStr,
 678}
 679
 680#[derive(Debug)]
 681enum SmallVec<T> {
 682    None,
 683    One(T),
 684    Many(Vec<T>),
 685}
 686
 687impl<T: Copy> SmallVec<T> {
 688    #[allow(clippy::type_complexity)]
 689    fn iter(
 690        &self,
 691    ) -> Either<std::iter::Empty<T>, Either<std::iter::Once<T>, Copied<std::slice::Iter<T>>>> {
 692        match self {
 693            Self::None => Either::Left(std::iter::empty()),
 694            Self::One(v) => Either::Right(Either::Left(std::iter::once(*v))),
 695            Self::Many(v) => Either::Right(Either::Right(v.iter().copied())),
 696        }
 697    }
 698}
 699
 700impl<'a> CommitInner<'a> {
 701    pub fn new(commit: gix::worktree::object::CommitRef<'a>, oid: [u8; 20]) -> Result<Self> {
 702        let message = commit.message();
 703
 704        Ok(CommitInner {
 705            author: CommitUser::try_from(commit.author)?,
 706            committer: CommitUser::try_from(commit.committer)?,
 707            oid,
 708            tree: commit.tree,
 709            parents: commit.parents.into_inner().map_or_else(
 710                |inner| {
 711                    if inner.is_empty() {
 712                        SmallVec::None
 713                    } else {
 714                        SmallVec::Many(inner.into_vec())
 715                    }
 716                },
 717                |[v]| SmallVec::One(v),
 718            ),
 719
 720            summary: message.summary(),
 721            body: message.body.unwrap_or_else(|| BStr::new("")),
 722        })
 723    }
 724}
 725
 726impl CommitInner<'_> {
 727    pub fn author(&self) -> &CommitUser<'_> {
 728        &self.author
 729    }
 730
 731    pub fn committer(&self) -> &CommitUser<'_> {
 732        &self.committer
 733    }
 734
 735    pub fn oid(&self) -> DisplayHexBuffer<20> {
 736        let mut buf = const_hex::Buffer::new();
 737        buf.format(&self.oid);
 738        DisplayHexBuffer(buf)
 739    }
 740
 741    pub fn tree(&self) -> &BStr {
 742        self.tree
 743    }
 744
 745    pub fn parents(&self) -> impl Iterator<Item = &BStr> {
 746        self.parents.iter()
 747    }
 748
 749    pub fn summary(&self) -> &BStr {
 750        &self.summary
 751    }
 752
 753    pub fn body(&self) -> &BStr {
 754        self.body
 755    }
 756}
 757
 758#[instrument(skip(repo, commit))]
 759fn fetch_diff_and_stats(
 760    repo: &gix::Repository,
 761    commit: &gix::Commit<'_>,
 762    highlight: bool,
 763) -> Result<(String, String)> {
 764    const WIDTH: usize = 80;
 765
 766    let current_tree = commit.tree().context("Couldn't get tree for the commit")?;
 767    let parent_tree = commit
 768        .ancestors()
 769        .first_parent_only()
 770        .all()?
 771        .nth(1)
 772        .transpose()?
 773        .map(|v| v.object())
 774        .transpose()?
 775        .map(|v| v.tree())
 776        .transpose()?
 777        .unwrap_or_else(|| repo.empty_tree());
 778
 779    let mut diffs = Vec::new();
 780    let mut diff_output = String::new();
 781
 782    let mut resource_cache = repo.diff_resource_cache_for_tree_diff()?;
 783
 784    let mut changes = parent_tree.changes()?;
 785    changes.options(|opts| {
 786        opts.track_path().track_rewrites(None);
 787    });
 788    changes.for_each_to_obtain_tree_with_cache(
 789        &current_tree,
 790        &mut repo.diff_resource_cache_for_tree_diff()?,
 791        |change| {
 792            if highlight {
 793                DiffBuilder {
 794                    output: &mut diff_output,
 795                    resource_cache: &mut resource_cache,
 796                    diffs: &mut diffs,
 797                    formatter: SyntaxHighlightedDiffFormatter::new(
 798                        change.location().to_path().unwrap(),
 799                    ),
 800                }
 801                .handle(change)
 802            } else {
 803                DiffBuilder {
 804                    output: &mut diff_output,
 805                    resource_cache: &mut resource_cache,
 806                    diffs: &mut diffs,
 807                    formatter: PlainDiffFormatter,
 808                }
 809                .handle(change)
 810            }
 811        },
 812    )?;
 813
 814    let (max_file_name_length, max_change_length, files_changed, insertions, deletions) =
 815        diffs.iter().fold(
 816            (0, 0, 0, 0, 0),
 817            |(max_file_name_length, max_change_length, files_changed, insertions, deletions),
 818             stats| {
 819                (
 820                    max_file_name_length.max(stats.path.len()),
 821                    max_change_length
 822                        .max(((stats.insertions + stats.deletions + 1).ilog10() + 1) as usize),
 823                    files_changed + 1,
 824                    insertions + stats.insertions,
 825                    deletions + stats.deletions,
 826                )
 827            },
 828        );
 829
 830    let mut diff_stats = String::new();
 831
 832    let total_changes = insertions + deletions;
 833
 834    for diff in &diffs {
 835        let local_changes = diff.insertions + diff.deletions;
 836        let width = WIDTH.min(local_changes);
 837
 838        // Calculate proportions of `+` and `-` within the total width
 839        let addition_width = (width * diff.insertions) / total_changes;
 840        let deletion_width = (width * diff.deletions) / total_changes;
 841
 842        // Handle edge case where total width is less than total changes
 843        let remaining_width = width - (addition_width + deletion_width);
 844        let adjusted_addition_width = addition_width + remaining_width.min(diff.insertions);
 845        let adjusted_deletion_width =
 846            deletion_width + (remaining_width - remaining_width.min(diff.insertions));
 847
 848        // Generate the string representation
 849        let plus_str = "+".repeat(adjusted_addition_width);
 850        let minus_str = "-".repeat(adjusted_deletion_width);
 851
 852        let file = diff.path.as_str();
 853        writeln!(diff_stats, " {file:max_file_name_length$} | {local_changes:max_change_length$} {plus_str}{minus_str}").unwrap();
 854    }
 855
 856    for (i, (singular_desc, plural_desc, amount)) in [
 857        ("file changed", "files changed", files_changed),
 858        ("insertion(+)", "insertions(+)", insertions),
 859        ("deletion(-)", "deletions(-)", deletions),
 860    ]
 861    .into_iter()
 862    .enumerate()
 863    {
 864        if amount == 0 {
 865            continue;
 866        }
 867
 868        let prefix = if i == 0 { "" } else { "," };
 869
 870        let desc = if amount == 1 {
 871            singular_desc
 872        } else {
 873            plural_desc
 874        };
 875
 876        write!(diff_stats, "{prefix} {amount} {desc}")?;
 877    }
 878
 879    // TODO: emit 'create mode 100644 pure-black-background-f82588d3.jpg' here
 880
 881    writeln!(diff_stats)?;
 882
 883    Ok((diff_output, diff_stats))
 884}
 885
 886#[derive(Default, Debug)]
 887struct FileDiff {
 888    path: String,
 889    insertions: usize,
 890    deletions: usize,
 891}
 892
 893trait DiffFormatter {
 894    fn file_header(&self, output: &mut String, data: fmt::Arguments<'_>);
 895
 896    fn binary(
 897        &self,
 898        output: &mut String,
 899        left: &str,
 900        right: &str,
 901        left_content: &[u8],
 902        right_content: &[u8],
 903    );
 904}
 905
 906struct DiffBuilder<'a, F> {
 907    output: &'a mut String,
 908    resource_cache: &'a mut gix::diff::blob::Platform,
 909    diffs: &'a mut Vec<FileDiff>,
 910    formatter: F,
 911}
 912
 913impl<F: DiffFormatter + Callback> DiffBuilder<'_, F> {
 914    #[allow(clippy::too_many_lines)]
 915    fn handle(
 916        &mut self,
 917        change: gix::object::tree::diff::Change<'_, '_, '_>,
 918    ) -> Result<gix::object::tree::diff::Action> {
 919        if !change.entry_mode().is_blob_or_symlink() {
 920            return Ok(gix::object::tree::diff::Action::Continue);
 921        }
 922
 923        let mut diff = FileDiff {
 924            path: change.location().to_string(),
 925            insertions: 0,
 926            deletions: 0,
 927        };
 928        let change = change.diff(self.resource_cache)?;
 929
 930        let prep = change.resource_cache.prepare_diff()?;
 931
 932        self.formatter.file_header(
 933            self.output,
 934            format_args!(
 935                "diff --git a/{} b/{}",
 936                prep.old.rela_path, prep.new.rela_path
 937            ),
 938        );
 939
 940        if prep.old.id.is_null() {
 941            self.formatter.file_header(
 942                self.output,
 943                format_args!("new file mode {}", prep.new.mode.as_octal_str()),
 944            );
 945        } else if prep.new.id.is_null() {
 946            self.formatter.file_header(
 947                self.output,
 948                format_args!("deleted file mode {}", prep.old.mode.as_octal_str()),
 949            );
 950        } else if prep.new.mode != prep.old.mode {
 951            self.formatter.file_header(
 952                self.output,
 953                format_args!("old mode {}", prep.old.mode.as_octal_str()),
 954            );
 955            self.formatter.file_header(
 956                self.output,
 957                format_args!("new mode {}", prep.new.mode.as_octal_str()),
 958            );
 959        }
 960
 961        // copy from
 962        // copy to
 963        // rename old
 964        // rename new
 965        // rename from
 966        // rename to
 967        // similarity index
 968        // dissimilarity index
 969
 970        let (index_suffix_sep, index_suffix) = if prep.old.mode == prep.new.mode {
 971            (" ", prep.new.mode.as_octal_str())
 972        } else {
 973            ("", BStr::new(&[]))
 974        };
 975
 976        let old_path = if prep.old.id.is_null() {
 977            Cow::Borrowed("/dev/null")
 978        } else {
 979            Cow::Owned(format!("a/{}", prep.old.rela_path))
 980        };
 981
 982        let new_path = if prep.new.id.is_null() {
 983            Cow::Borrowed("/dev/null")
 984        } else {
 985            Cow::Owned(format!("a/{}", prep.new.rela_path))
 986        };
 987
 988        match prep.operation {
 989            Operation::InternalDiff { algorithm } => {
 990                self.formatter.file_header(
 991                    self.output,
 992                    format_args!(
 993                        "index {}..{}{index_suffix_sep}{index_suffix}",
 994                        prep.old.id.to_hex_with_len(7),
 995                        prep.new.id.to_hex_with_len(7)
 996                    ),
 997                );
 998                self.formatter
 999                    .file_header(self.output, format_args!("--- {old_path}"));
1000                self.formatter
1001                    .file_header(self.output, format_args!("+++ {new_path}"));
1002
1003                let old_source = gix::diff::blob::sources::lines_with_terminator(
1004                    simdutf8::basic::from_utf8(prep.old.data.as_slice().unwrap_or_default())?,
1005                );
1006                let new_source = gix::diff::blob::sources::lines_with_terminator(
1007                    simdutf8::basic::from_utf8(prep.new.data.as_slice().unwrap_or_default())?,
1008                );
1009                let input = gix::diff::blob::intern::InternedInput::new(old_source, new_source);
1010
1011                let output = gix::diff::blob::diff(
1012                    algorithm,
1013                    &input,
1014                    UnifiedDiffBuilder::with_writer(&input, &mut *self.output, &mut self.formatter)
1015                        .with_counter(),
1016                );
1017
1018                diff.deletions += output.removals as usize;
1019                diff.insertions += output.insertions as usize;
1020            }
1021            Operation::ExternalCommand { .. } => {}
1022            Operation::SourceOrDestinationIsBinary => {
1023                self.formatter.file_header(
1024                    self.output,
1025                    format_args!(
1026                        "index {}..{}{index_suffix_sep}{index_suffix}",
1027                        prep.old.id, prep.new.id,
1028                    ),
1029                );
1030
1031                self.formatter.binary(
1032                    self.output,
1033                    old_path.as_ref(),
1034                    new_path.as_ref(),
1035                    prep.old.data.as_slice().unwrap_or_default(),
1036                    prep.new.data.as_slice().unwrap_or_default(),
1037                );
1038            }
1039        }
1040
1041        self.diffs.push(diff);
1042
1043        self.resource_cache.clear_resource_cache_keep_allocation();
1044        Ok(gix::object::tree::diff::Action::Continue)
1045    }
1046}
1047
1048struct PlainDiffFormatter;
1049
1050impl DiffFormatter for PlainDiffFormatter {
1051    fn file_header(&self, output: &mut String, data: fmt::Arguments<'_>) {
1052        writeln!(output, "{data}").unwrap();
1053    }
1054
1055    fn binary(
1056        &self,
1057        output: &mut String,
1058        left: &str,
1059        right: &str,
1060        _left_content: &[u8],
1061        _right_content: &[u8],
1062    ) {
1063        // todo: actually perform the diff and write a `GIT binary patch` out
1064        writeln!(output, "Binary files {left} and {right} differ").unwrap();
1065    }
1066}
1067
1068impl Callback for PlainDiffFormatter {
1069    fn addition(&mut self, data: &str, dst: &mut String) {
1070        write!(dst, "+{data}").unwrap();
1071    }
1072
1073    fn remove(&mut self, data: &str, dst: &mut String) {
1074        write!(dst, "-{data}").unwrap();
1075    }
1076
1077    fn context(&mut self, data: &str, dst: &mut String) {
1078        write!(dst, " {data}").unwrap();
1079    }
1080}
1081
1082struct SyntaxHighlightedDiffFormatter<'a> {
1083    path: &'a Path,
1084}
1085
1086impl<'a> SyntaxHighlightedDiffFormatter<'a> {
1087    fn new(path: &'a Path) -> Self {
1088        Self { path }
1089    }
1090
1091    fn write(&self, output: &mut String, class: &str, data: &str) {
1092        write!(output, r#"<span class="diff-{class}">"#).unwrap();
1093        format_file_inner(output, data, FileIdentifier::Path(self.path), false).unwrap();
1094        output.push_str("</span>");
1095    }
1096}
1097
1098impl DiffFormatter for SyntaxHighlightedDiffFormatter<'_> {
1099    fn file_header(&self, output: &mut String, data: Arguments<'_>) {
1100        write!(output, r#"<span class="diff-file-header">"#).unwrap();
1101        write!(output, "{data}").unwrap();
1102        output.push_str("</span>\n");
1103    }
1104
1105    fn binary(
1106        &self,
1107        output: &mut String,
1108        left: &str,
1109        right: &str,
1110        _left_content: &[u8],
1111        _right_content: &[u8],
1112    ) {
1113        write!(output, "Binary files {left} and {right} differ").unwrap();
1114    }
1115}
1116
1117impl Callback for SyntaxHighlightedDiffFormatter<'_> {
1118    fn addition(&mut self, data: &str, dst: &mut String) {
1119        self.write(dst, "add-line", data);
1120    }
1121
1122    fn remove(&mut self, data: &str, dst: &mut String) {
1123        self.write(dst, "remove-line", data);
1124    }
1125
1126    fn context(&mut self, data: &str, dst: &mut String) {
1127        self.write(dst, "context", data);
1128    }
1129}