use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::model::{DiffLine, DiffModel, FileDiff, FileStatus, Hunk, HunkId, LineKind, hunk_id};
use crate::vcs::{BranchInfo, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError};
pub const DEFAULT_CONTEXT_LINES: u32 = 3;
pub struct GitVcs {
repo: git2::Repository,
context_lines: u32,
}
impl GitVcs {
pub fn open(root: &Path) -> Result<Self, VcsError> {
Self::open_with_context(root, DEFAULT_CONTEXT_LINES)
}
pub fn open_with_context(root: &Path, context_lines: u32) -> Result<Self, VcsError> {
let repo = git2::Repository::open(root)?;
if repo.workdir().is_none() {
return Err(VcsError::NoWorkdir);
}
Ok(Self {
repo,
context_lines,
})
}
fn head_tree(&self) -> Result<Option<git2::Tree<'_>>, VcsError> {
match self.repo.head() {
Ok(head) => Ok(Some(head.peel_to_tree()?)),
Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
Err(err) => Err(err.into()),
}
}
fn workdir_path(&self) -> Result<&Path, VcsError> {
self.repo.workdir().ok_or(VcsError::NoWorkdir)
}
fn workdir_diff(&self, base: Option<&git2::Tree<'_>>) -> Result<DiffModel, VcsError> {
let mut diff = self.repo.diff_tree_to_workdir_with_index(
base,
Some(&mut workdir_diff_options(self.context_lines)),
)?;
let mut find = git2::DiffFindOptions::new();
find.renames(true);
diff.find_similar(Some(&mut find))?;
diff_to_model(&self.repo, &mut diff)
}
fn walk_entries(
&self,
walk: git2::Revwalk<'_>,
limit: usize,
) -> Result<Vec<LogEntry>, VcsError> {
let mut entries = Vec::new();
for oid in walk.take(limit) {
let oid = oid?;
let commit = self.repo.find_commit(oid)?;
let full = oid.to_string();
entries.push(LogEntry {
oid7: short7(&full),
oid: full,
refs: Vec::new(),
subject: commit.summary()?.unwrap_or_default().to_owned(),
author: commit.author().name().unwrap_or_default().to_owned(),
time_unix: commit.time().seconds(),
});
}
Ok(entries)
}
fn has_tracked_changes(&self) -> Result<bool, VcsError> {
let mut opts = git2::StatusOptions::new();
opts.include_untracked(false).include_ignored(false);
let statuses = self.repo.statuses(Some(&mut opts))?;
Ok(statuses.iter().next().is_some())
}
}
impl Vcs for GitVcs {
fn git_dir(&self) -> Result<PathBuf, VcsError> {
Ok(self.repo.path().to_path_buf())
}
fn head(&self) -> Result<HeadInfo, VcsError> {
match self.repo.head() {
Ok(head) => {
let branch = if head.is_branch() {
Some(head.shorthand()?.to_owned())
} else {
None
};
let commit = head.peel_to_commit()?;
let tracking = branch.as_deref().and_then(|name| {
let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
let upstream = local.upstream().ok()?;
let name = upstream.name().ok().flatten()?.to_owned();
Some((name, upstream.get().target()))
});
let (upstream, ahead, behind) = match tracking {
Some((name, Some(target))) => {
let (ahead, behind) = self
.repo
.graph_ahead_behind(commit.id(), target)
.unwrap_or((0, 0));
(Some(name), ahead, behind)
}
Some((name, None)) => (Some(name), 0, 0),
None => (None, 0, 0),
};
Ok(HeadInfo {
branch,
oid7: short7(&commit.id().to_string()),
subject: commit.summary()?.unwrap_or_default().to_owned(),
upstream,
ahead,
behind,
})
}
Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
let branch = self
.repo
.find_reference("HEAD")
.ok()
.and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
.and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
Ok(HeadInfo {
branch,
oid7: String::new(),
subject: String::new(),
upstream: None,
ahead: 0,
behind: 0,
})
}
Err(err) => Err(err.into()),
}
}
fn status(&self) -> Result<StatusModel, VcsError> {
let mut workdir = self
.repo
.diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
let workdir_model = diff_to_model(&self.repo, &mut workdir)?;
let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
.files
.into_iter()
.partition(|f| f.status == FileStatus::Untracked);
let head_tree = self.head_tree()?;
let mut opts = git2::DiffOptions::new();
opts.context_lines(self.context_lines);
let mut staged = self
.repo
.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
let staged = diff_to_model(&self.repo, &mut staged)?;
Ok(StatusModel {
untracked: DiffModel { files: untracked },
unstaged: DiffModel { files: unstaged },
staged,
})
}
fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
self.workdir_diff(self.head_tree()?.as_ref())
}
fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
self.workdir_diff(Some(&base.tree()?))
}
fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
let oid = git2::Oid::from_str(oid)?;
let commit = self.repo.find_commit(oid)?;
let tree = commit.tree()?;
let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
let mut opts = git2::DiffOptions::new();
opts.context_lines(self.context_lines);
let mut diff =
self.repo
.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?;
diff_to_model(&self.repo, &mut diff)
}
fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
let mut opts = git2::DiffOptions::new();
opts.context_lines(self.context_lines);
let mut diff = self.repo.diff_tree_to_tree(
Some(&base.tree()?),
Some(&newest.tree()?),
Some(&mut opts),
)?;
diff_to_model(&self.repo, &mut diff)
}
fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
let base = self
.repo
.merge_base(git2::Oid::from_str(a)?, git2::Oid::from_str(b)?)?;
Ok(base.to_string())
}
fn resolve(&self, revision: &str) -> Result<String, VcsError> {
let object = self.repo.revparse_single(revision)?;
let commit = object.peel_to_commit()?;
Ok(commit.id().to_string())
}
fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
let newest_tree = newest.tree()?;
let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
let mut opts = git2::DiffOptions::new();
opts.context_lines(self.context_lines);
let mut diff =
self.repo
.diff_tree_to_tree(base_tree.as_ref(), Some(&newest_tree), Some(&mut opts))?;
diff_to_model(&self.repo, &mut diff)
}
fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError> {
if self.head_tree()?.is_none() {
return Ok(Vec::new());
}
let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
for reference in self.repo.references()?.flatten() {
let Ok(name) = reference.shorthand().map(str::to_owned) else {
continue;
};
let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
continue;
};
refs_by_oid.entry(target).or_default().push(name);
}
let mut walk = self.repo.revwalk()?;
walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
walk.push_head()?;
let mut entries = Vec::new();
for oid in walk.take(limit) {
let oid = oid?;
let commit = self.repo.find_commit(oid)?;
let full = oid.to_string();
entries.push(LogEntry {
oid7: short7(&full),
oid: full,
refs: refs_by_oid.get(&oid).cloned().unwrap_or_default(),
subject: commit.summary()?.unwrap_or_default().to_owned(),
author: commit.author().name().unwrap_or_default().to_owned(),
time_unix: commit.time().seconds(),
});
}
Ok(entries)
}
fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
let head_ref = format!("refs/remotes/{remote}/HEAD");
let prefix = format!("refs/remotes/{remote}/");
if let Ok(reference) = self.repo.find_reference(&head_ref)
&& let Ok(Some(target)) = reference.symbolic_target()
&& let Some(name) = target.strip_prefix(prefix.as_str())
{
return Ok(Some(name.to_owned()));
}
for name in ["main", "master"] {
if self
.repo
.find_branch(name, git2::BranchType::Local)
.or_else(|_| {
self.repo
.find_branch(&format!("{remote}/{name}"), git2::BranchType::Remote)
})
.is_ok()
{
return Ok(Some(name.to_owned()));
}
}
Ok(None)
}
fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
let (base, head) = (
self.repo.revparse_single(base)?,
self.repo.revparse_single(head)?,
);
let mut walk = self.repo.revwalk()?;
walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
walk.push(head.id())?;
walk.hide(base.id())?;
self.walk_entries(walk, usize::MAX)
}
fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
let Ok(head) = self.repo.head() else {
return Ok(None);
};
let mut walk = self.repo.revwalk()?;
walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
walk.push(head.peel_to_commit()?.id())?;
let mut remotes = 0;
for reference in self.repo.references()? {
let reference = reference?;
if let (true, Some(oid)) = (reference.is_remote(), reference.target()) {
remotes += 1;
walk.hide(oid)?;
}
}
if remotes == 0 {
return Ok(None);
}
self.walk_entries(walk, limit).map(Some)
}
fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
let mut out = Vec::new();
for entry in self.repo.branches(Some(git2::BranchType::Local))? {
let (branch, _) = entry?;
let Some(name) = branch.name()?.map(str::to_owned) else {
continue;
};
let tip = branch.get().peel_to_commit().ok();
let tip_unix = tip.as_ref().map_or(0, |c| c.time().seconds());
let upstream_target = branch.upstream().ok().and_then(|u| u.get().target());
let (ahead, behind) = match (&tip, upstream_target) {
(Some(tip), Some(target)) => self
.repo
.graph_ahead_behind(tip.id(), target)
.unwrap_or((0, 0)),
_ => (0, 0),
};
out.push(BranchInfo {
name,
is_head: branch.is_head(),
tip_unix,
ahead,
behind,
});
}
Ok(out)
}
fn all_branches(&self) -> Result<Vec<String>, VcsError> {
let mut out = Vec::new();
for entry in self.repo.branches(None)? {
let (branch, _) = entry?;
let Some(name) = branch.name()?.map(str::to_owned) else {
continue;
};
if name.ends_with("/HEAD") {
continue;
}
out.push(name);
}
Ok(out)
}
fn stage(&self, rel: &Path) -> Result<(), VcsError> {
let root = self.workdir_path()?;
let mut index = self.repo.index()?;
if root.join(rel).exists() {
index.add_path(rel)?;
} else {
index.remove_path(rel)?;
}
index.write()?;
Ok(())
}
fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
let diff = self
.repo
.diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
let patch = synthesize_patch(&diff, rel, hunk, false)?;
let diff = git2::Diff::from_buffer(&patch)?;
self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
Ok(())
}
fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
match self.repo.head() {
Ok(head) => {
let target = head.peel(git2::ObjectType::Commit)?;
self.repo.reset_default(Some(&target), [rel])?;
}
Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
let mut index = self.repo.index()?;
index.remove_path(rel)?;
index.write()?;
}
Err(err) => return Err(err.into()),
}
Ok(())
}
fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
let head_tree = self.head_tree()?;
let mut opts = git2::DiffOptions::new();
opts.context_lines(self.context_lines);
let diff = self
.repo
.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
let patch = synthesize_patch(&diff, rel, hunk, true)?;
let diff = git2::Diff::from_buffer(&patch)?;
self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
Ok(())
}
fn discard(&self, rel: &Path) -> Result<(), VcsError> {
let head_tree = self.head_tree()?;
let mut opts = git2::DiffOptions::new();
opts.pathspec(rel).disable_pathspec_match(true);
let staged = self
.repo
.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
if staged.deltas().len() > 0 {
return Err(VcsError::Rejected(
"file has staged changes; unstage first".into(),
));
}
let status = self.repo.status_file(rel)?;
if status.contains(git2::Status::WT_NEW) {
fs::remove_file(self.workdir_path()?.join(rel))?;
return Ok(());
}
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.path(rel).force().update_index(true);
self.repo.checkout_head(Some(&mut checkout))?;
Ok(())
}
fn commit(&self, message: &str) -> Result<String, VcsError> {
if message.trim().is_empty() {
return Err(VcsError::Rejected("empty commit message".into()));
}
let mut index = self.repo.index()?;
let tree_id = index.write_tree()?;
let tree = self.repo.find_tree(tree_id)?;
let signature = self.repo.signature()?;
let parent = match self.repo.head() {
Ok(head) => Some(head.peel_to_commit()?),
Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
Err(err) => return Err(err.into()),
};
let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
let oid = self.repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parents,
)?;
Ok(oid.to_string())
}
fn head_message(&self) -> Result<String, VcsError> {
let commit = self.repo.head()?.peel_to_commit()?;
Ok(commit.message().unwrap_or_default().to_owned())
}
fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
if let Some(message) = message
&& message.trim().is_empty()
{
return Err(VcsError::Rejected("empty commit message".into()));
}
let head = self.repo.head()?.peel_to_commit()?;
let tree = if use_index {
let mut index = self.repo.index()?;
let tree_id = index.write_tree()?;
self.repo.find_tree(tree_id)?
} else {
head.tree()?
};
let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
Ok(oid.to_string())
}
fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
let head = self.repo.head()?.peel_to_commit()?;
self.repo.branch(name, &head, false)?;
if checkout {
self.checkout(name)?;
}
Ok(())
}
fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
if branch.is_head() {
return Err(VcsError::Rejected(
"cannot delete the checked-out branch".into(),
));
}
branch.delete()?;
Ok(())
}
fn checkout(&self, name: &str) -> Result<(), VcsError> {
let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
let target = branch.get().peel(git2::ObjectType::Commit)?;
self.repo.checkout_tree(&target, None)?;
self.repo.set_head(&format!("refs/heads/{name}"))?;
Ok(())
}
fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
if !self.has_tracked_changes()? {
return Err(VcsError::Rejected("nothing to stash".into()));
}
let mut repo = git2::Repository::open(self.workdir_path()?)?;
let signature = repo.signature()?;
repo.stash_save2(&signature, message, None)?;
Ok(())
}
fn stash_pop(&self) -> Result<(), VcsError> {
let mut repo = git2::Repository::open(self.workdir_path()?)?;
match repo.stash_pop(0, None) {
Ok(()) => Ok(()),
Err(err) if err.code() == git2::ErrorCode::NotFound => {
Err(VcsError::Rejected("no stash to pop".into()))
}
Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
"stash applied with conflicts; resolve them (the stash was kept)".into(),
)),
Err(err) => Err(err.into()),
}
}
fn network_argv(&self, op: NetworkOp) -> Vec<String> {
let args: &[&str] = match op {
NetworkOp::Fetch => &["fetch"],
NetworkOp::FetchAll => &["fetch", "--all"],
};
std::iter::once("git")
.chain(args.iter().copied())
.map(str::to_owned)
.collect()
}
fn workdir(&self) -> Result<PathBuf, VcsError> {
Ok(self.workdir_path()?.to_path_buf())
}
fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
match self.repo.find_remote(name) {
Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
fn remotes(&self) -> Result<Vec<String>, VcsError> {
let array = self.repo.remotes()?;
let mut names = Vec::new();
for i in 0..array.len() {
if let Ok(Some(name)) = array.get(i) {
names.push(name.to_owned());
}
}
Ok(names)
}
}
fn synthesize_patch(
diff: &git2::Diff<'_>,
rel: &Path,
target: &HunkId,
reverse: bool,
) -> Result<Vec<u8>, VcsError> {
let rel = rel.to_string_lossy();
for idx in 0..diff.deltas().len() {
let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
continue;
};
let delta = patch.delta();
if delta.flags().is_binary() || delta_new_path(&delta) != rel {
continue;
}
for h in 0..patch.num_hunks() {
if hunk_id(&rel, &hunk_model_lines(&patch, h)?) == *target {
return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
}
}
}
Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
}
fn render_hunk_patch(
patch: &git2::Patch<'_>,
h: usize,
rel: &str,
status: git2::Delta,
reverse: bool,
) -> Result<Vec<u8>, VcsError> {
let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
let deleted = status == git2::Delta::Deleted;
let mut out = Vec::new();
out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
if (added && !reverse) || (deleted && reverse) {
out.extend_from_slice(
format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
);
} else if (deleted && !reverse) || (added && reverse) {
out.extend_from_slice(
format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
);
} else {
out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
}
let (hunk, line_count) = patch.hunk(h)?;
let (old, new) = (
(hunk.old_start(), hunk.old_lines()),
(hunk.new_start(), hunk.new_lines()),
);
let ((minus_start, minus_lines), (plus_start, plus_lines)) =
if reverse { (new, old) } else { (old, new) };
out.extend_from_slice(
format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
);
for l in 0..line_count {
let line = patch.line_in_hunk(h, l)?;
let origin = match (line.origin(), reverse) {
(' ', _) => Some(b' '),
('+', false) | ('-', true) => Some(b'+'),
('-', false) | ('+', true) => Some(b'-'),
('=' | '>' | '<', _) => None,
_ => continue,
};
if let Some(origin) = origin {
out.push(origin);
}
out.extend_from_slice(line.content());
}
Ok(out)
}
fn workdir_diff_options(context_lines: u32) -> git2::DiffOptions {
let mut opts = git2::DiffOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(true)
.context_lines(context_lines);
opts
}
fn short7(oid: &str) -> String {
oid.get(..7).unwrap_or(oid).to_owned()
}
fn diff_to_model(
repo: &git2::Repository,
diff: &mut git2::Diff<'_>,
) -> Result<DiffModel, VcsError> {
let mut files = Vec::new();
for idx in 0..diff.deltas().len() {
if let Some(file) = build_file(repo, diff, idx)? {
files.push(file);
}
}
Ok(DiffModel { files })
}
fn build_file(
repo: &git2::Repository,
diff: &mut git2::Diff<'_>,
idx: usize,
) -> Result<Option<FileDiff>, VcsError> {
let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
return Ok(build_binary_file(diff, idx));
};
let delta = patch.delta();
if delta.flags().is_binary() {
return Ok(build_binary_file(diff, idx));
}
let file_path = delta_new_path(&delta);
let status = map_status(delta.status());
let old_path = if status == FileStatus::Renamed {
delta
.old_file()
.path()
.map(|p| p.to_string_lossy().into_owned())
} else {
None
};
let old_text = blob_text(repo, delta.old_file().id());
let new_text = new_side_text(repo, &delta, &file_path);
let hunks = patch_hunks(&patch, &file_path)?;
Ok(Some(FileDiff {
path: file_path,
old_path,
status,
binary: false,
old_text,
new_text,
hunks,
hashes: crate::model::HashCache::default(),
}))
}
pub fn rehunk_file(file: &FileDiff, context: u32) -> Option<Vec<Hunk>> {
if file.binary {
return None;
}
let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
let as_path = Path::new(&file.path);
let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
let mut opts = git2::DiffOptions::new();
opts.context_lines(context.min(cap));
let patch = git2::Patch::from_buffers(
old.as_bytes(),
Some(as_path),
new.as_bytes(),
Some(as_path),
Some(&mut opts),
)
.ok()?;
patch_hunks(&patch, &file.path).ok()
}
fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
let mut hunks = Vec::with_capacity(patch.num_hunks());
for h in 0..patch.num_hunks() {
let (hunk, _) = patch.hunk(h)?;
let lines = hunk_model_lines(patch, h)?;
let id = hunk_id(file_path, &lines);
hunks.push(Hunk {
id,
old_start: hunk.old_start(),
old_lines: hunk.old_lines(),
new_start: hunk.new_start(),
new_lines: hunk.new_lines(),
context: hunk_context(&hunk),
lines,
});
}
Ok(hunks)
}
fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
let header = String::from_utf8_lossy(hunk.header());
match header.split_once(" @@") {
Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
None => String::new(),
}
}
fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
let (_, line_count) = patch.hunk(h)?;
let mut lines = Vec::with_capacity(line_count);
for l in 0..line_count {
let line = patch.line_in_hunk(h, l)?;
let kind = match line.origin() {
'-' => LineKind::Deleted,
'+' => LineKind::Added,
' ' => LineKind::Context,
_ => continue,
};
let text = String::from_utf8_lossy(line.content())
.trim_end_matches(['\n', '\r'])
.to_owned();
lines.push(DiffLine::new(
kind,
line.old_lineno(),
line.new_lineno(),
text,
));
}
Ok(lines)
}
fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
let delta = diff.get_delta(idx)?;
Some(FileDiff {
path: delta_new_path(&delta),
old_path: None,
status: map_status(delta.status()),
binary: true,
old_text: None,
new_text: None,
hunks: Vec::new(),
hashes: crate::model::HashCache::default(),
})
}
fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn map_status(status: git2::Delta) -> FileStatus {
match status {
git2::Delta::Added => FileStatus::Added,
git2::Delta::Deleted => FileStatus::Deleted,
git2::Delta::Renamed => FileStatus::Renamed,
git2::Delta::Untracked => FileStatus::Untracked,
_ => FileStatus::Modified,
}
}
fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
if oid.is_zero() {
return None;
}
let blob = repo.find_blob(oid).ok()?;
if blob.is_binary() {
return None;
}
String::from_utf8(blob.content().to_vec()).ok()
}
fn new_side_text(
repo: &git2::Repository,
delta: &git2::DiffDelta<'_>,
rel: &str,
) -> Option<String> {
if delta.status() == git2::Delta::Deleted {
return None;
}
if let Some(text) = blob_text(repo, delta.new_file().id()) {
return Some(text);
}
let root = repo.workdir()?;
fs::read_to_string(root.join(rel)).ok()
}