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