use std::collections::{HashMap, HashSet};
use std::path::Path;
use git2::{
BranchType, Commit, Delta, DiffFormat, DiffLineType, DiffOptions, Oid, Repository, Sort,
};
use super::{
BlobPair, BranchInfo, ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange,
RefKind, RefLabel, RepoBackend, WorkingStatus,
};
const MAX_RENAME_PAIRS: usize = 100_000;
const MAX_DIFF_LINES: usize = 50_000;
pub struct Git2Backend {
path: String,
repo: Repository,
commits: Vec<CommitInfo>,
}
impl Git2Backend {
pub fn open(path: impl AsRef<str>) -> Result<Self, git2::Error> {
let path = path.as_ref().to_string();
let repo = Repository::discover(&path)?;
let display_path = repo
.workdir()
.map(|p| p.display().to_string())
.unwrap_or_else(|| repo.path().display().to_string());
let refs = collect_refs(&repo)?;
let commits = load_commits(&repo, &refs)?;
Ok(Self {
path: display_path,
repo,
commits,
})
}
fn commit_at(&self, index: usize) -> Option<Commit<'_>> {
let info = self.commits.get(index)?;
let oid = Oid::from_str(&info.id).ok()?;
self.repo.find_commit(oid).ok()
}
fn build_diff(&self, index: usize, path: Option<&str>) -> Option<git2::Diff<'_>> {
let commit = self.commit_at(index)?;
let new_tree = commit.tree().ok()?;
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let mut opts = DiffOptions::new();
opts.context_lines(3);
if let Some(path) = path {
opts.pathspec(path);
}
let mut diff = self
.repo
.diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))
.ok()?;
detect_renames(&mut diff);
Some(diff)
}
fn blob_in_tree(&self, tree: &git2::Tree, path: &str) -> Option<Vec<u8>> {
let entry = tree.get_path(Path::new(path)).ok()?;
let obj = entry.to_object(&self.repo).ok()?;
obj.as_blob().map(|b| b.content().to_vec())
}
fn blob_in_index(&self, path: &str) -> Option<Vec<u8>> {
let index = self.repo.index().ok()?;
let entry = index.get_path(Path::new(path), 0)?;
let blob = self.repo.find_blob(entry.id).ok()?;
Some(blob.content().to_vec())
}
fn blob_in_workdir(&self, path: &str) -> Option<Vec<u8>> {
let workdir = self.repo.workdir()?;
std::fs::read(workdir.join(path)).ok()
}
fn review_base(&self) -> Option<(String, Oid)> {
let remote_default = self
.repo
.find_reference("refs/remotes/origin/HEAD")
.ok()
.and_then(|r| r.symbolic_target().map(str::to_string))
.and_then(|t| t.strip_prefix("refs/remotes/origin/").map(str::to_string));
let mut candidates: Vec<&str> = Vec::new();
if let Some(name) = remote_default.as_deref() {
candidates.push(name);
}
for name in ["main", "master"] {
if !candidates.contains(&name) {
candidates.push(name);
}
}
for name in candidates {
if let Ok(branch) = self.repo.find_branch(name, BranchType::Local)
&& let Ok(tip) = branch.get().peel_to_commit()
{
return Some((name.to_string(), tip.id()));
}
let remote_name = format!("origin/{name}");
if let Ok(branch) = self.repo.find_branch(&remote_name, BranchType::Remote)
&& let Ok(tip) = branch.get().peel_to_commit()
{
return Some((remote_name, tip.id()));
}
}
let head = self.repo.head().ok()?;
let name = head.shorthand().unwrap_or("HEAD").to_string();
let tip = head.peel_to_commit().ok()?;
Some((name, tip.id()))
}
fn branch_info(
&self,
name: String,
kind: RefKind,
tip: &Commit,
upstream: Option<String>,
base_name: &str,
base_oid: Oid,
) -> BranchInfo {
let base_id = self
.repo
.merge_base(base_oid, tip.id())
.ok()
.map(|oid| oid.to_string());
let author = tip.author();
BranchInfo {
name,
kind,
tip_id: tip.id().to_string(),
summary: tip.summary().unwrap_or("").to_string(),
author: author.name().unwrap_or("").to_string(),
time_seconds: author.when().seconds(),
time_offset_minutes: author.when().offset_minutes(),
upstream,
base_name: base_name.to_string(),
base_id,
}
}
fn branch_trees(
&self,
branch: &BranchInfo,
) -> Option<(Option<git2::Tree<'_>>, git2::Tree<'_>)> {
let tip_oid = Oid::from_str(&branch.tip_id).ok()?;
let tip = self.repo.find_commit(tip_oid).ok()?.tree().ok()?;
let base = branch
.base_id
.as_deref()
.and_then(|id| Oid::from_str(id).ok())
.and_then(|oid| self.repo.find_commit(oid).ok())
.and_then(|c| c.tree().ok());
Some((base, tip))
}
fn build_branch_diff(&self, branch: &BranchInfo, path: Option<&str>) -> Option<git2::Diff<'_>> {
let (base, tip) = self.branch_trees(branch)?;
let mut opts = DiffOptions::new();
opts.context_lines(3);
if let Some(path) = path {
opts.pathspec(path);
}
let mut diff = self
.repo
.diff_tree_to_tree(base.as_ref(), Some(&tip), Some(&mut opts))
.ok()?;
detect_renames(&mut diff);
Some(diff)
}
fn staged_base_tree(&self, amend: bool) -> Option<git2::Tree<'_>> {
let head = self.repo.head().ok()?.peel_to_commit().ok()?;
if amend {
head.parent(0).ok().and_then(|p| p.tree().ok())
} else {
head.tree().ok()
}
}
}
impl RepoBackend for Git2Backend {
fn path(&self) -> &str {
&self.path
}
fn commits(&self) -> &[CommitInfo] {
&self.commits
}
fn changed_files(&self, index: usize) -> Vec<FileChange> {
let Some(diff) = self.build_diff(index, None) else {
return Vec::new();
};
diff.deltas()
.map(|delta| file_change_from_delta(&delta))
.collect()
}
fn commit_diff(&self, index: usize) -> Diff {
self.build_diff(index, None)
.map(render_diff)
.unwrap_or_default()
}
fn file_diff(&self, index: usize, path: &str) -> Diff {
self.build_diff(index, Some(path))
.map(render_diff)
.unwrap_or_default()
}
fn commit_file_blobs(&self, index: usize, path: &str) -> BlobPair {
let Some(commit) = self.commit_at(index) else {
return BlobPair::default();
};
let new = commit.tree().ok().and_then(|t| self.blob_in_tree(&t, path));
let old = commit
.parent(0)
.ok()
.and_then(|p| p.tree().ok())
.and_then(|t| self.blob_in_tree(&t, path));
BlobPair { old, new }
}
fn branches(&self) -> Vec<BranchInfo> {
let Some((base_name, base_oid)) = self.review_base() else {
return Vec::new();
};
let mut branches = Vec::new();
let mut folded: HashSet<String> = HashSet::new();
if let Ok(iter) = self.repo.branches(Some(BranchType::Local)) {
for (branch, _) in iter.flatten() {
let Ok(Some(name)) = branch.name() else {
continue;
};
let name = name.to_string();
let Ok(tip) = branch.get().peel_to_commit() else {
continue;
};
let kind = if branch.is_head() {
RefKind::Head
} else {
RefKind::LocalBranch
};
let upstream = branch.upstream().ok().and_then(|u| {
let uname = u.name().ok().flatten()?.to_string();
let utip = u.get().peel_to_commit().ok()?.id();
(utip == tip.id()).then_some(uname)
});
if let Some(uname) = &upstream {
folded.insert(uname.clone());
}
branches.push(self.branch_info(name, kind, &tip, upstream, &base_name, base_oid));
}
}
if let Ok(iter) = self.repo.branches(Some(BranchType::Remote)) {
for (branch, _) in iter.flatten() {
let Ok(Some(name)) = branch.name() else {
continue;
};
if name.ends_with("/HEAD") || folded.contains(name) {
continue;
}
let name = name.to_string();
let Ok(tip) = branch.get().peel_to_commit() else {
continue;
};
branches.push(self.branch_info(
name,
RefKind::RemoteBranch,
&tip,
None,
&base_name,
base_oid,
));
}
}
branches.sort_by(|a, b| {
let rank = |k: RefKind| match k {
RefKind::Head | RefKind::DetachedHead => 0,
RefKind::LocalBranch => 1,
_ => 2,
};
rank(a.kind).cmp(&rank(b.kind)).then(a.name.cmp(&b.name))
});
branches
}
fn branch_files(&self, branch: &BranchInfo) -> Vec<FileChange> {
let Some(diff) = self.build_branch_diff(branch, None) else {
return Vec::new();
};
diff.deltas()
.map(|delta| file_change_from_delta(&delta))
.collect()
}
fn branch_diff(&self, branch: &BranchInfo) -> Diff {
self.build_branch_diff(branch, None)
.map(render_diff)
.unwrap_or_default()
}
fn branch_file_diff(&self, branch: &BranchInfo, path: &str) -> Diff {
self.build_branch_diff(branch, Some(path))
.map(render_diff)
.unwrap_or_default()
}
fn branch_file_blobs(&self, branch: &BranchInfo, path: &str) -> BlobPair {
let Some((base, tip)) = self.branch_trees(branch) else {
return BlobPair::default();
};
BlobPair {
old: base.as_ref().and_then(|t| self.blob_in_tree(t, path)),
new: self.blob_in_tree(&tip, path),
}
}
fn working_file_blobs(&self, path: &str, staged: bool, amend: bool) -> BlobPair {
if staged {
let old = self
.staged_base_tree(amend)
.and_then(|t| self.blob_in_tree(&t, path));
BlobPair {
old,
new: self.blob_in_index(path),
}
} else {
BlobPair {
old: self.blob_in_index(path),
new: self.blob_in_workdir(path),
}
}
}
fn working_status(&self, amend: bool) -> WorkingStatus {
let base = self.staged_base_tree(amend);
let mut staged_opts = DiffOptions::new();
let mut staged = WorkingStatus::default();
if let Ok(mut diff) =
self.repo
.diff_tree_to_index(base.as_ref(), None, Some(&mut staged_opts))
{
let _ = diff.find_similar(None);
for delta in diff.deltas() {
staged.staged.push(file_change_from_delta(&delta));
}
}
let mut wd_opts = DiffOptions::new();
wd_opts.include_untracked(true).recurse_untracked_dirs(true);
if let Ok(diff) = self.repo.diff_index_to_workdir(None, Some(&mut wd_opts)) {
for delta in diff.deltas() {
staged.unstaged.push(file_change_from_delta(&delta));
}
}
staged
}
fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff {
let mut opts = DiffOptions::new();
opts.context_lines(3).pathspec(path);
let diff = if staged {
let base = self.staged_base_tree(amend);
self.repo
.diff_tree_to_index(base.as_ref(), None, Some(&mut opts))
} else {
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(true);
self.repo.diff_index_to_workdir(None, Some(&mut opts))
};
diff.ok().map(render_diff).unwrap_or_default()
}
fn stage(&self, path: &str) -> Result<(), String> {
let mut index = self.repo.index().map_err(err_msg)?;
let p = Path::new(path);
let in_workdir = self
.repo
.workdir()
.map(|w| w.join(path).exists())
.unwrap_or(false);
if in_workdir {
index.add_path(p).map_err(err_msg)?;
} else {
index.remove_path(p).map_err(err_msg)?;
}
index.write().map_err(err_msg)
}
fn unstage(&self, path: &str, amend: bool) -> Result<(), String> {
let head = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let target: Option<git2::Object> = match (amend, head) {
(false, Some(commit)) => Some(commit.into_object()),
(true, Some(commit)) => commit.parent(0).ok().map(|p| p.into_object()),
(_, None) => None,
};
match target {
Some(obj) => self.repo.reset_default(Some(&obj), [path]).map_err(err_msg),
None => {
let mut index = self.repo.index().map_err(err_msg)?;
index.remove_path(Path::new(path)).map_err(err_msg)?;
index.write().map_err(err_msg)
}
}
}
fn revert(&self, path: &str) -> Result<(), String> {
let mut opts = git2::build::CheckoutBuilder::new();
opts.force().update_index(false).path(path);
self.repo
.checkout_index(None, Some(&mut opts))
.map_err(err_msg)
}
fn delete_untracked(&self, path: &str) -> Result<(), String> {
let workdir = self
.repo
.workdir()
.ok_or_else(|| "bare repository has no working tree".to_string())?;
std::fs::remove_file(workdir.join(path)).map_err(|e| e.to_string())
}
fn apply_to_index(&self, patch: &str) -> Result<(), String> {
let diff = git2::Diff::from_buffer(patch.as_bytes()).map_err(err_msg)?;
self.repo
.apply(&diff, git2::ApplyLocation::Index, None)
.map_err(err_msg)
}
fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
if message.trim().is_empty() {
return Err("Please enter a commit message.".into());
}
let mut index = self.repo.index().map_err(err_msg)?;
let tree_oid = index.write_tree().map_err(err_msg)?;
let tree = self.repo.find_tree(tree_oid).map_err(err_msg)?;
if amend {
let head = self
.repo
.head()
.and_then(|h| h.peel_to_commit())
.map_err(err_msg)?;
head.amend(Some("HEAD"), None, None, None, Some(message), Some(&tree))
.map_err(err_msg)?;
} else {
let sig = self.repo.signature().map_err(|_| {
"No git identity configured. Set user.name and user.email.".to_string()
})?;
let parent = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&Commit> = parent.iter().collect();
self.repo
.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
.map_err(err_msg)?;
}
Ok(())
}
fn head_message(&self) -> Option<String> {
let commit = self.repo.head().ok()?.peel_to_commit().ok()?;
Some(commit.message().unwrap_or("").to_string())
}
fn signature(&self) -> Option<(String, String)> {
let sig = self.repo.signature().ok()?;
Some((sig.name()?.to_string(), sig.email()?.to_string()))
}
}
fn file_change_from_delta(delta: &git2::DiffDelta) -> FileChange {
let new_path = delta.new_file().path().map(|p| p.display().to_string());
let old_path = delta.old_file().path().map(|p| p.display().to_string());
let status = status_from_delta(delta.status());
let path = new_path
.clone()
.or_else(|| old_path.clone())
.unwrap_or_default();
FileChange {
path,
old_path: old_path.filter(|o| Some(o) != new_path.as_ref()),
status,
}
}
fn err_msg(e: git2::Error) -> String {
e.message().to_string()
}
fn collect_refs(repo: &Repository) -> Result<HashMap<Oid, Vec<RefLabel>>, git2::Error> {
let mut map: HashMap<Oid, Vec<RefLabel>> = HashMap::new();
let head = repo.head().ok();
let head_branch = head
.as_ref()
.filter(|h| h.is_branch())
.and_then(|h| h.shorthand())
.map(str::to_string);
let detached = repo.head_detached().unwrap_or(false);
if detached && let Some(oid) = head.as_ref().and_then(|h| h.target()) {
map.entry(oid).or_default().push(RefLabel {
name: "HEAD".into(),
kind: RefKind::DetachedHead,
});
}
if let Ok(references) = repo.references() {
for reference in references.flatten() {
let Ok(commit) = reference.peel_to_commit() else {
continue;
};
let oid = commit.id();
let Some(name) = reference.shorthand().map(str::to_string) else {
continue;
};
let kind = if reference.is_tag() {
RefKind::Tag
} else if reference.is_remote() {
if name.ends_with("/HEAD") {
continue;
}
RefKind::RemoteBranch
} else if reference.is_branch() {
if head_branch.as_deref() == Some(name.as_str()) {
RefKind::Head
} else {
RefKind::LocalBranch
}
} else {
continue;
};
map.entry(oid).or_default().push(RefLabel { name, kind });
}
}
for labels in map.values_mut() {
labels.sort_by_key(|l| match l.kind {
RefKind::Head | RefKind::DetachedHead => 0,
RefKind::LocalBranch => 1,
RefKind::RemoteBranch => 2,
RefKind::Tag => 3,
});
}
Ok(map)
}
fn load_commits(
repo: &Repository,
refs: &HashMap<Oid, Vec<RefLabel>>,
) -> Result<Vec<CommitInfo>, git2::Error> {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
let _ = revwalk.push_head();
let mut commits = Vec::new();
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
commits.push(commit_info(&commit, refs));
}
Ok(commits)
}
fn commit_info(commit: &Commit, refs: &HashMap<Oid, Vec<RefLabel>>) -> CommitInfo {
let id = commit.id().to_string();
let short_id = id.chars().take(8).collect();
let message = commit.message().unwrap_or("").to_string();
let summary = commit
.summary()
.map(str::to_string)
.unwrap_or_else(|| message.lines().next().unwrap_or("").to_string());
let author = commit.author();
let committer = commit.committer();
let time = author.when();
CommitInfo {
short_id,
summary,
message,
author_name: author.name().unwrap_or("").to_string(),
author_email: author.email().unwrap_or("").to_string(),
committer_name: committer.name().unwrap_or("").to_string(),
committer_email: committer.email().unwrap_or("").to_string(),
time_seconds: time.seconds(),
time_offset_minutes: time.offset_minutes(),
parents: commit.parent_ids().map(|p| p.to_string()).collect(),
refs: refs.get(&commit.id()).cloned().unwrap_or_default(),
id,
}
}
fn status_from_delta(delta: Delta) -> ChangeStatus {
match delta {
Delta::Added => ChangeStatus::Added,
Delta::Deleted => ChangeStatus::Deleted,
Delta::Modified => ChangeStatus::Modified,
Delta::Renamed => ChangeStatus::Renamed,
Delta::Copied => ChangeStatus::Copied,
Delta::Typechange => ChangeStatus::TypeChange,
Delta::Untracked => ChangeStatus::Untracked,
_ => ChangeStatus::Other,
}
}
fn detect_renames(diff: &mut git2::Diff) {
let (mut added, mut deleted) = (0usize, 0usize);
for delta in diff.deltas() {
match delta.status() {
Delta::Added => added += 1,
Delta::Deleted => deleted += 1,
_ => {}
}
}
if added.saturating_mul(deleted) <= MAX_RENAME_PAIRS {
let _ = diff.find_similar(None);
}
}
fn render_diff(diff: git2::Diff) -> Diff {
let mut lines = Vec::new();
let mut truncated = false;
let _ = diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
if lines.len() >= MAX_DIFF_LINES {
truncated = true;
return false;
}
let content = String::from_utf8_lossy(line.content());
let content = content.trim_end_matches('\n');
match line.origin_value() {
DiffLineType::FileHeader => {
push_multiline(&mut lines, DiffLineKind::FileHeader, content)
}
DiffLineType::HunkHeader => {
push_multiline(&mut lines, DiffLineKind::HunkHeader, content)
}
DiffLineType::Context => {
lines.push(DiffLine::new(DiffLineKind::Context, format!(" {content}")))
}
DiffLineType::Addition => {
lines.push(DiffLine::new(DiffLineKind::Addition, format!("+{content}")))
}
DiffLineType::Deletion => {
lines.push(DiffLine::new(DiffLineKind::Deletion, format!("-{content}")))
}
DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => {
lines.push(DiffLine::new(DiffLineKind::Meta, content.to_string()))
}
_ => push_multiline(&mut lines, DiffLineKind::Meta, content),
}
true
});
if truncated {
lines.push(DiffLine::new(
DiffLineKind::Meta,
format!(
"\u{2026} diff truncated at {MAX_DIFF_LINES} lines — too large to display in full"
),
));
}
Diff { lines }
}
fn push_multiline(out: &mut Vec<DiffLine>, kind: DiffLineKind, content: &str) {
for line in content.split('\n') {
out.push(DiffLine::new(kind, line.to_string()));
}
}