src/git.rs
/*
* Copyright 2023 Trevor Bentley
*
* Author: Trevor Bentley
* Contact: gitsy@@trevorbentley.com
* Source: https://github.com/mrmekon/itsy-gitsy
*
* This file is part of Itsy-Gitsy.
*
* Itsy-Gitsy is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Itsy-Gitsy is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Itsy-Gitsy. If not, see <http://www.gnu.org/licenses/>.
*/
use crate::settings::GitsySettingsRepo;
use crate::util::{sanitize_path_component, urlify_path, SafePathVar};
use crate::{error, loud, louder, loudest};
use git2::{DiffOptions, Error, Repository};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
fn first_line(msg: &[u8]) -> String {
let message = String::from_utf8_lossy(msg);
message.lines().next().unwrap_or("[no commit message]").to_owned()
}
#[derive(Serialize, Default)]
pub struct GitRepo {
pub name: String,
pub last_ts_utc: i64,
pub last_ts_offset: i64,
pub metadata: GitsyMetadata,
pub history: Vec<GitObject>,
pub branches: Vec<GitObject>,
pub tags: Vec<GitObject>,
pub root_files: Vec<GitFile>,
pub all_files: Vec<GitFile>,
pub commits: BTreeMap<String, GitObject>,
// TODO: this is duplication that should be handled with
// references. Used so templates can deduce which files have been
// generated.
pub commit_ids: Vec<String>,
pub file_ids: Vec<String>,
}
impl GitRepo {
pub fn minimal_clone(&self, max_entries: usize) -> Self {
let mut new_commits: BTreeMap<String, GitObject> = BTreeMap::new();
let new_history: Vec<GitObject> = self.history.iter().cloned().take(max_entries).collect();
for entry in &new_history {
if self.commits.contains_key(&entry.full_hash) {
new_commits.insert(
entry.full_hash.clone(),
self.commits.get(&entry.full_hash).unwrap().clone(),
);
}
}
let all_files: Vec<GitFile> = self.all_files.iter().cloned().take(max_entries).collect();
GitRepo {
name: self.name.clone(),
last_ts_utc: self.last_ts_utc,
last_ts_offset: self.last_ts_offset,
metadata: self.metadata.clone(),
history: new_history,
branches: self.branches.iter().cloned().take(max_entries).collect(),
tags: self.tags.iter().cloned().take(max_entries).collect(),
// Don't minimize the root tree, because that's weird UX
// for the summary page.
root_files: self.root_files.clone(),
all_files,
commits: new_commits,
// These are not minimized because they're a listing of
// which generated files should exist, and are needed for
// ensuring valid links on every page.
file_ids: self.file_ids.clone(),
commit_ids: self.commit_ids.clone(),
}
}
}
impl SafePathVar for GitRepo {
fn safe_substitute(&self, path: &impl AsRef<Path>) -> PathBuf {
let src: &Path = path.as_ref();
let mut dst = PathBuf::new();
let safe_name = sanitize_path_component(&self.name);
for cmp in src.components() {
let cmp = cmp.as_os_str().to_string_lossy().replace("%REPO%", &safe_name);
dst.push(cmp);
}
assert!(
src.components().count() == dst.components().count(),
"ERROR: path substitution accidentally created a new folder in: {}",
src.display()
);
dst
}
}
#[derive(Clone, Serialize, Default)]
pub struct GitsyMetadata {
pub full_name: Option<String>,
pub description: Option<String>,
pub website: Option<String>,
pub clone: Option<String>,
pub attributes: BTreeMap<String, toml::Value>,
}
#[derive(Clone, Serialize, Default)]
pub struct GitAuthor {
pub name: Option<String>,
pub email: Option<String>,
}
#[derive(Clone, Serialize, Default)]
pub struct GitObject {
pub full_hash: String,
pub short_hash: String,
pub ts_utc: i64,
pub ts_offset: i64,
pub author: GitAuthor,
pub committer: GitAuthor,
pub parents: Vec<String>,
pub ref_name: Option<String>,
pub alt_refs: Vec<String>,
pub tagged_id: Option<String>,
pub tree_id: Option<String>,
pub summary: Option<String>,
pub message: Option<String>,
pub stats: Option<GitStats>,
pub diff: Option<GitDiffCommit>,
}
impl SafePathVar for GitObject {
fn safe_substitute(&self, path: &impl AsRef<Path>) -> PathBuf {
let src: &Path = path.as_ref();
let mut dst = PathBuf::new();
let safe_full_hash = sanitize_path_component(&self.full_hash);
let safe_ref = self
.ref_name
.as_deref()
.map(|v| sanitize_path_component(&urlify_path(v)))
.unwrap_or("%REF%".to_string());
for cmp in src.components() {
let cmp = cmp
.as_os_str()
.to_string_lossy()
.replace("%ID%", &safe_full_hash)
.replace("%REF%", &safe_ref);
dst.push(cmp);
}
assert!(
src.components().count() == dst.components().count(),
"ERROR: path substitution accidentally created a new folder in: {}",
src.display()
);
dst
}
}
#[derive(Clone, Serialize, Default)]
pub struct GitStats {
pub files: usize,
pub additions: usize,
pub deletions: usize,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct GitFile {
pub id: String,
pub name: String,
pub path: String,
pub mode: i32,
pub kind: String,
pub is_binary: bool,
pub size: usize,
pub tree_depth: usize,
pub contents: Option<String>,
pub contents_safe: bool,
pub contents_preformatted: bool,
}
impl SafePathVar for GitFile {
fn safe_substitute(&self, path: &impl AsRef<Path>) -> PathBuf {
let src: &Path = path.as_ref();
let mut dst = PathBuf::new();
let safe_id = sanitize_path_component(&self.id);
let safe_name = sanitize_path_component(&self.name);
let safe_path = sanitize_path_component(&urlify_path(&self.path));
for cmp in src.components() {
let cmp = cmp
.as_os_str()
.to_string_lossy()
.replace("%ID%", &safe_id)
.replace("%NAME%", &safe_name)
.replace("%PATH%", &safe_path);
dst.push(cmp);
}
assert!(
src.components().count() == dst.components().count(),
"ERROR: path substitution accidentally created a new folder in: {}",
src.display()
);
dst
}
}
#[derive(Clone, Serialize, Default)]
pub struct GitDiffCommit {
pub files: Vec<GitDiffFile>,
pub file_count: usize,
pub additions: usize,
pub deletions: usize,
}
#[derive(Clone, Serialize, Default)]
pub struct GitDiffFile {
pub oldfile: String,
pub newfile: String,
pub basefile: String,
pub oldid: String,
pub newid: String,
pub extra: String,
pub additions: usize,
pub deletions: usize,
pub hunks: Vec<GitDiffHunk>,
}
#[derive(Clone, Serialize, Default)]
pub struct GitDiffHunk {
pub context: String,
pub lines: Vec<GitDiffLine>,
}
#[derive(Clone, Serialize)]
pub struct GitDiffLine {
pub kind: &'static str,
pub prefix: &'static str,
pub text: String,
}
fn walk_file_tree(
repo: &git2::Repository,
rev: &str,
files: &mut Vec<GitFile>,
depth: usize,
max_depth: usize,
recurse: bool,
prefix: &str,
) -> Result<(), Error> {
let obj = repo.revparse_single(rev)?;
let tree = obj.peel_to_tree()?;
for entry in tree.iter() {
let name = entry.name().unwrap_or_default().to_string();
let path = prefix.to_string() + entry.name().unwrap_or_default();
let kind = match entry.kind() {
Some(git2::ObjectType::Tree) => "dir",
Some(git2::ObjectType::Blob) => "file",
Some(git2::ObjectType::Commit) => "submodule",
_ => "unknown",
};
let mut is_binary = false;
let mut size = 0;
if let Ok(blob) = repo.find_blob(entry.id()) {
is_binary = blob.is_binary();
size = blob.content().len();
}
loudest!(" + file: {}", path);
files.push(GitFile {
id: entry.id().to_string(),
name: name.clone(),
path: path.clone(),
kind: kind.to_string(),
mode: entry.filemode(),
is_binary,
size,
tree_depth: depth,
contents: None,
contents_safe: false,
contents_preformatted: true,
});
if recurse && depth < (max_depth - 1) && entry.kind() == Some(git2::ObjectType::Tree) {
let prefix = path + "/";
walk_file_tree(
repo,
&entry.id().to_string(),
files,
depth + 1,
max_depth,
true,
&prefix,
)?;
}
}
Ok(())
}
pub fn dir_listing(repo: &Repository, file: &GitFile) -> Result<Vec<GitFile>, Error> {
let mut files: Vec<GitFile> = vec![];
walk_file_tree(
&repo,
&file.id,
&mut files,
0,
usize::MAX,
false,
&(file.path.clone() + "/"),
)?;
Ok(files)
}
pub fn parse_revwalk(
repo: &Repository,
mut revwalk: git2::Revwalk,
references: &BTreeMap<String, Vec<String>>,
settings: &GitsySettingsRepo,
max_diffs: usize,
) -> Result<Vec<GitObject>, Error> {
let mut history: Vec<GitObject> = vec![];
for (idx, oid) in revwalk.by_ref().enumerate() {
let oid = oid?;
if idx >= settings.limit_history.unwrap_or(usize::MAX) {
break;
}
let parsed = parse_commit(idx, settings, repo, &oid.to_string(), &references,
history.len() < max_diffs)?;
loudest!(
" + [{}] {} {}",
idx,
parsed.full_hash,
parsed.summary.as_deref().unwrap_or_default()
);
history.push(parsed);
}
Ok(history)
}
pub fn parse_repo(
repo: &Repository,
name: &str,
settings: &GitsySettingsRepo,
metadata: GitsyMetadata,
) -> Result<GitRepo, Error> {
let mut branches: Vec<GitObject> = vec![];
let mut tags: Vec<GitObject> = vec![];
let mut commits: BTreeMap<String, GitObject> = BTreeMap::new();
let mut branch_count = 0;
let mut tag_count = 0;
let branch_name = settings.branch.as_deref().unwrap_or("master");
let branch_obj = repo.revparse_single(branch_name)?;
loud!();
// Cache the shortnames of all references
loudest!(" - Parsing references");
let mut references: BTreeMap<String, Vec<String>> = BTreeMap::new();
for refr in repo.references()? {
let refr = refr?;
if let (Some(target), Some(name)) = (refr.target(), refr.shorthand()) {
let id = match refr.peel_to_tag() {
Ok(tag) => tag.target_id().to_string(),
_ => target.to_string(),
};
match references.contains_key(&id) {
false => {
references.insert(id, vec![name.to_string()]);
}
true => {
references.get_mut(&id).unwrap().push(name.to_string());
}
}
}
}
loud!(" - parsed {} references", references.len());
loudest!(" - Parsing history:");
// Figure out how many commits we have, to determine whether we
// should parallelize. Unfortunately, git doesn't optimize for
// counting commits... this is a heavy operation.
let commit_count = {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(git2::Sort::NONE)?;
// Using first parent counts the "mainline" commits, rather than
// the commits on the merged in branches. These are also the
// commits thare a accessible via "HEAD~{N}" references.
revwalk.simplify_first_parent()?;
revwalk.push(branch_obj.id())?;
revwalk.count().min(settings.limit_history.unwrap_or(usize::MAX))
};
// Let's arbitrarily say it's not worth parallelizing unless we
// can give all cores at least 1k commits to parse. This could
// certainly use some configurability...
let thread_jobs = match rayon::current_num_threads() > 1 && commit_count > 1000 * rayon::current_num_threads() {
// Divide a chunk up into even smaller units, so each core
// runs about 10. This makes it more efficient to detect when
// the commit limit is reached and short-circuit.
true => rayon::current_num_threads() * 10,
false => 1,
};
// Chunk size is only an estimate, since we used
// simplify_first_parent() above, and do not use it below. Each
// thread will include `chunk_size` direct parent commits, *plus*
// all commits from branches that merged into that range. This
// might not be evenly distributed.
let chunk_size = ((commit_count as f64) / (thread_jobs as f64)).ceil() as usize;
if thread_jobs > 1 {
loud!(
" - splitting {} commits across {} threads of approximate size {}",
commit_count,
thread_jobs,
chunk_size
);
}
let repo_path = repo.path();
// Make a list of thread IDs to execute. Note the subtle rev() to
// do this in the right order.
let thread_jobs: Vec<usize> = (0..thread_jobs).rev().collect();
// Make a matching list of the maximum number of diffs to
// calculate on each thread. It's unknown how many commits each
// thread will find, but each should find at least `chunk_size`.
let diffs: Vec<usize> = thread_jobs
.iter()
.scan(settings.limit_diffs.unwrap_or(usize::MAX), |acc, _x| {
match (*acc > 0, chunk_size > *acc) {
(true, true) => {
let old_acc = *acc;
*acc = 0;
Some(old_acc)
}
(true, false) => {
*acc -= chunk_size;
Some(chunk_size)
}
_ => Some(0),
}
})
.collect();
let zipped_thread_jobs: Vec<(usize, usize)> = thread_jobs.iter().cloned().zip(diffs).collect();
let atomic_commits = AtomicUsize::new(0);
let mut history: Vec<_> = zipped_thread_jobs
.par_iter()
.try_fold(
|| Vec::<_>::new(),
|mut acc, (thread, max_diffs)| {
if atomic_commits.load(Ordering::SeqCst) > settings.limit_history.unwrap_or(usize::MAX) {
// TODO: should convert all error paths in this function
// to GitsyErrors, and differentiate between real failures
// and soft limits. For now, they're all stop processing,
// but don't raise any errors. Here, we take advantage of
// that.
return Err(git2::Error::from_str("history limit reached"));
}
let repo = Repository::open(repo_path)?;
let mut revwalk = repo.revwalk()?;
// TODO: TOPOLOGICAL might be better, but it's also ungodly slow
// on large repos. Maybe this should be configurable.
//
//revwalk.set_sorting(git2::Sort::TOPOLOGICAL)?;
revwalk.set_sorting(git2::Sort::NONE)?;
let start_commit = match (chunk_size * thread) + 1 > commit_count {
true => 1,
false => commit_count - 1 - (chunk_size * thread),
};
let end_commit = match chunk_size > start_commit {
true => "".into(),
false => format!("~{}", start_commit - chunk_size),
};
let range = format!("{}~{}..{}{}", branch_name, start_commit, branch_name, end_commit);
loud!(" - Parse range: {} on thread {}", range, thread);
match *thread == 0 {
true => {
// The last chunk gets a single ref instead of a
// range, because ranges can't seem to represent the
// very first commit in a repository...
let end_commit = format!("{}{}", branch_name, end_commit);
let branch_obj = repo.revparse_single(&end_commit).unwrap();
revwalk.push(branch_obj.id())?
}
false => revwalk.push_range(&range)?,
}
let res = parse_revwalk(&repo, revwalk, &references, &settings, *max_diffs)?;
louder!(" - Parsed {} on thread {}", res.len(), thread);
atomic_commits.fetch_add(res.len(), Ordering::SeqCst);
acc.extend(res);
Ok(acc)
},
)
.map(|x: Result<Vec<GitObject>, Error>| x.ok())
.while_some()
.flatten_iter() // concatenate all of the vecs in series
.collect();
// Have to truncate, because the logic above can overshoot.
history.truncate(settings.limit_history.unwrap_or(usize::MAX));
let history_count = history.len();
// TODO: very inefficient memory usage: all commits are cloned.
// Also done linearly, so this takes some time for large repos.
for commit in &history {
let _ = commits.insert(commit.full_hash.clone(), commit.clone());
}
loud!(" - parsed {} commits", history_count);
loudest!(" - Parsing branches:");
for branch in repo.branches(None)? {
if branch_count >= settings.limit_branches.unwrap_or(usize::MAX) {
break;
}
let (branch, _branch_type) = branch?;
let refr = branch.get();
let name = branch.name()?.unwrap_or("[unnamed]");
let obj = repo.revparse_single(name)?;
// Only show direct references, skip symbolic aliases. Maybe
// this is a bad idea?
match refr.kind() {
Some(k) if k == git2::ReferenceType::Symbolic => continue,
_ => {}
}
let commit = repo.find_commit(obj.id())?;
let full_hash = obj.id().to_string();
let short_hash = obj.short_id()?.as_str().unwrap_or_default().to_string();
loudest!(" + {} {}", full_hash, name);
branches.push(GitObject {
full_hash,
short_hash,
ts_utc: commit.author().when().seconds(),
ts_offset: (commit.author().when().offset_minutes() as i64) * 60,
parents: vec![],
ref_name: Some(name.to_string()),
author: GitAuthor {
name: commit.author().name().map(|x| x.to_owned()),
email: commit.author().email().map(|x| x.to_owned()),
},
committer: GitAuthor {
name: commit.committer().name().map(|x| x.to_owned()),
email: commit.committer().email().map(|x| x.to_owned()),
},
summary: Some(first_line(commit.message_bytes())),
message: commit.message().map(|x| x.to_string()),
..Default::default()
});
branch_count += 1;
}
loud!(" - parsed {} branches", branch_count);
loudest!(" - Parsing tags:");
for tag in repo.tag_names(None)?.iter().rev() {
if tag_count >= settings.limit_tags.unwrap_or(usize::MAX) {
break;
}
let tag = tag.unwrap_or("[unnamed]");
let obj = repo.revparse_single(tag)?;
let full_hash = obj.id().to_string();
let short_hash = obj.short_id()?.as_str().unwrap_or_default().to_string();
let commit = match repo.find_tag(obj.id()) {
Ok(c) => c,
Err(_e) => {
error!("WARNING: tag commit not found for tag: {}", obj.id().to_string());
tags.push(GitObject {
full_hash,
short_hash,
ref_name: Some(tag.to_string()),
..Default::default()
});
tag_count += 1;
continue;
}
};
let (ts, tz) = match commit.tagger() {
Some(t) => (t.when().seconds(), (t.when().offset_minutes() as i64) * 60),
_ => (0, 0),
};
let (author, email) = match commit.tagger() {
Some(t) => (t.name().map(|x| x.to_owned()), t.email().map(|x| x.to_owned())),
_ => (None, None),
};
let summary = match commit.message_bytes() {
Some(m) => Some(first_line(m)),
_ => None,
};
loudest!(" + {} {}", full_hash, tag);
tags.push(GitObject {
full_hash,
short_hash,
ts_utc: ts,
ts_offset: tz,
ref_name: Some(tag.to_string()),
author: GitAuthor { name: author, email },
tagged_id: Some(commit.target_id().to_string()),
message: commit.message().map(|x| x.to_string()),
summary,
..Default::default()
});
tag_count += 1;
}
loud!(" - parsed {} tags", tag_count);
let mut root_files: Vec<GitFile> = vec![];
let mut all_files: Vec<GitFile> = vec![];
let max_depth = settings.limit_tree_depth.unwrap_or(usize::MAX);
if max_depth > 0 {
loudest!(" - Walking root files");
walk_file_tree(&repo, branch_name, &mut root_files, 0, usize::MAX, false, "")?;
// TODO: maybe this should be optional? Walking the whole tree
// could be slow on huge repos.
loudest!(" - Walking all files");
walk_file_tree(&repo, branch_name, &mut all_files, 0, max_depth, true, "")?;
}
loud!(" - parsed {} files", all_files.len());
let file_ids = all_files.iter().map(|x| x.id.clone()).collect();
let commit_ids = commits.keys().cloned().collect();
Ok(GitRepo {
name: name.to_string(),
last_ts_utc: history.first().map(|x| x.ts_utc).unwrap_or(0),
last_ts_offset: history.first().map(|x| x.ts_offset).unwrap_or(0),
metadata,
history,
branches,
tags,
root_files,
all_files,
commits,
commit_ids,
file_ids,
})
}
pub fn parse_commit(
_idx: usize,
_settings: &GitsySettingsRepo,
repo: &Repository,
refr: &str,
references: &BTreeMap<String, Vec<String>>,
with_diff: bool,
) -> Result<GitObject, Error> {
let obj = repo.revparse_single(refr)?;
let commit = repo.find_commit(obj.id())?;
let alt_refs: Vec<String> = references
.get(&commit.id().to_string())
.map(|x| x.to_owned())
.unwrap_or_default();
let mut parents: Vec<String> = vec![];
let a = match commit.parents().len() {
x if x == 1 => {
let parent = commit.parent(0).unwrap();
parents.push(parent.id().to_string());
Some(parent.tree()?)
}
x if x > 1 => {
for parent in commit.parents() {
parents.push(parent.id().to_string());
}
let parent = commit.parent(0).unwrap();
Some(parent.tree()?)
}
_ => None,
};
let (stats, commit_diff) = match with_diff {
false => (None, None),
true => {
let b = commit.tree()?;
let mut diffopts = DiffOptions::new();
diffopts.enable_fast_untracked_dirs(true);
let diff = repo.diff_tree_to_tree(a.as_ref(), Some(&b), Some(&mut diffopts))?;
let stats = diff.stats()?;
let commit_diff: Option<GitDiffCommit> = match with_diff {
true => Some(GitDiffCommit {
file_count: stats.files_changed(),
additions: stats.insertions(),
deletions: stats.deletions(),
..Default::default()
}),
false => None,
};
let stats = GitStats {
files: stats.files_changed(),
additions: stats.insertions(),
deletions: stats.deletions(),
};
let commit_diff = match commit_diff {
None => None,
Some(mut commit_diff) => {
let files: Rc<RefCell<Vec<GitDiffFile>>> = Rc::new(RefCell::new(vec![]));
diff.foreach(
&mut |file, _progress| {
let mut file_diff: GitDiffFile = Default::default();
file_diff.newfile = match file.status() {
git2::Delta::Deleted => "/dev/null".to_owned(),
_ => file
.new_file()
.path()
.map(|x| "b/".to_string() + &x.to_string_lossy())
.unwrap_or("/dev/null".to_string()),
};
file_diff.oldfile = match file.status() {
git2::Delta::Added => "/dev/null".to_owned(),
_ => file
.old_file()
.path()
.map(|x| "a/".to_string() + &x.to_string_lossy())
.unwrap_or("/dev/null".to_string()),
};
file_diff.basefile = match file.status() {
git2::Delta::Added => file
.new_file()
.path()
.map(|x| x.to_string_lossy().to_string())
.unwrap_or("/dev/null".to_string()),
_ => file
.old_file()
.path()
.map(|x| x.to_string_lossy().to_string())
.unwrap_or("/dev/null".to_string()),
};
file_diff.oldid = file.old_file().id().to_string();
file_diff.newid = file.new_file().id().to_string();
files.borrow_mut().push(file_diff);
true
},
None, // TODO: handle binary files?
Some(&mut |_file, hunk| {
let mut files = files.borrow_mut();
let file_diff: &mut GitDiffFile =
files.last_mut().expect("Diff hunk not associated with a file!");
let mut hunk_diff: GitDiffHunk = Default::default();
hunk_diff.context = String::from_utf8_lossy(hunk.header()).to_string();
file_diff.hunks.push(hunk_diff);
true
}),
Some(&mut |_file, _hunk, line| {
let mut files = files.borrow_mut();
let file_diff: &mut GitDiffFile =
files.last_mut().expect("Diff hunk not associated with a file!");
let hunk_diff: &mut GitDiffHunk = file_diff
.hunks
.last_mut()
.expect("Diff line not associated with a hunk!");
let (kind, prefix) = match line.origin() {
' ' => ("ctx", " "),
'-' => ("del", "-"),
'+' => ("add", "+"),
_ => ("other", " "),
};
match line.origin() {
'-' => file_diff.deletions += 1,
'+' => file_diff.additions += 1,
_ => {}
}
let line_diff = GitDiffLine {
text: String::from_utf8_lossy(line.content()).to_string(),
kind,
prefix,
};
hunk_diff.lines.push(line_diff);
true
}),
)?;
match Rc::try_unwrap(files) {
Ok(files) => {
let files: Vec<GitDiffFile> = files.into_inner();
commit_diff.files = files;
}
Err(_) => {}
}
Some(commit_diff)
}
};
(Some(stats), commit_diff)
}
};
let tree = obj.peel_to_tree()?;
let summary = GitObject {
full_hash: obj.id().to_string(),
short_hash: obj.short_id()?.as_str().unwrap_or_default().to_string(),
ts_utc: commit.author().when().seconds(),
ts_offset: (commit.author().when().offset_minutes() as i64) * 60,
tagged_id: None,
tree_id: Some(tree.id().to_string()),
parents,
ref_name: None,
alt_refs,
author: GitAuthor {
name: commit.author().name().map(|x| x.to_string()),
email: commit.author().email().map(|x| x.to_string()),
},
committer: GitAuthor {
name: commit.committer().name().map(|x| x.to_string()),
email: commit.committer().email().map(|x| x.to_string()),
},
summary: Some(first_line(commit.message_bytes())),
message: commit.message().map(|x| x.to_string()),
stats,
diff: commit_diff,
};
Ok(summary)
}