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