use crate::git::domain::diff::{compute_stats, parse_diff, DiffResult};
use anyhow::{Context, Result};
use git2::Repository;
use std::path::Path;
pub struct BranchInfo {
pub name: String,
pub is_head: bool,
}
pub struct CommitInfo {
pub short_hash: String,
pub full_hash: String,
pub author: String,
pub date: String,
pub message: String,
pub full_message: String,
pub parent_ids: Vec<String>,
}
pub struct CommitFileChange {
pub path: String,
pub status: char,
}
pub struct ReflogEntry {
pub short_hash: String,
pub full_hash: String,
pub selector: String,
pub action: String,
pub message: String,
}
pub struct Repo {
inner: Repository,
}
impl Repo {
pub fn discover(path: &Path) -> Result<Self> {
let repo = Repository::discover(path).context("Not a git repository")?;
Ok(Self { inner: repo })
}
pub fn workdir(&self) -> &Path {
self.inner.workdir().unwrap_or_else(|| self.inner.path())
}
pub fn branch_name(&self) -> String {
self.inner
.head()
.ok()
.and_then(|r| {
if r.is_branch() {
r.shorthand().map(|s| s.to_string())
} else {
r.target().map(|oid| format!("{:.7}", oid))
}
})
.unwrap_or_else(|| "HEAD".to_string())
}
pub fn diff_workdir(&self, base_ref: Option<&str>) -> Result<DiffResult> {
let files = parse_diff(&self.inner, base_ref)?;
let stats = compute_stats(&files);
let branch_name = self.branch_name();
Ok(DiffResult {
files,
branch_name,
stats,
})
}
pub fn list_local_branches(&self) -> Vec<BranchInfo> {
let head_name = self.branch_name();
let mut branches: Vec<BranchInfo> = match self.inner.branches(Some(git2::BranchType::Local))
{
Ok(iter) => iter
.filter_map(|b| b.ok())
.filter_map(|(branch, _)| {
branch.name().ok().flatten().map(|name| BranchInfo {
name: name.to_string(),
is_head: name == head_name,
})
})
.collect(),
Err(_) => Vec::new(),
};
branches.sort_by(|a, b| match (a.is_head, b.is_head) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
branches
}
pub fn log_for_ref(&self, ref_name: &str, limit: usize) -> Vec<CommitInfo> {
let obj = match self.inner.revparse_single(ref_name) {
Ok(obj) => obj,
Err(_) => return Vec::new(),
};
let mut revwalk = match self.inner.revwalk() {
Ok(rw) => rw,
Err(_) => return Vec::new(),
};
if revwalk.push(obj.id()).is_err() {
return Vec::new();
}
let _ = revwalk.set_sorting(git2::Sort::TIME);
let mut commits = Vec::new();
for oid in revwalk.take(limit) {
let oid = match oid {
Ok(o) => o,
Err(_) => break,
};
let commit = match self.inner.find_commit(oid) {
Ok(c) => c,
Err(_) => continue,
};
let hash_str = oid.to_string();
let short_hash = hash_str[..7.min(hash_str.len())].to_string();
let author = commit.author().name().unwrap_or("unknown").to_string();
let date = epoch_to_date(commit.time().seconds());
let message = commit.summary().unwrap_or("").to_string();
let full_message = commit.message().unwrap_or("").to_string();
let parent_ids = commit.parent_ids().map(|oid| oid.to_string()).collect();
commits.push(CommitInfo {
short_hash,
full_hash: hash_str,
author,
date,
message,
full_message,
parent_ids,
});
}
commits
}
pub fn switch_branch(&self, name: &str) -> Result<()> {
let workdir = self.workdir();
let output = std::process::Command::new("git")
.arg("switch")
.arg(name)
.current_dir(workdir)
.output()
.context("Failed to run git switch")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git switch failed: {}", stderr.trim());
}
Ok(())
}
pub fn delete_branch(&self, name: &str) -> Result<()> {
let workdir = self.workdir();
let output = std::process::Command::new("git")
.arg("branch")
.arg("-d")
.arg(name)
.current_dir(workdir)
.output()
.context("Failed to run git branch -d")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git branch -d failed: {}", stderr.trim());
}
Ok(())
}
pub fn reflog(&self, limit: usize) -> Vec<ReflogEntry> {
let reflog = match self.inner.reflog("HEAD") {
Ok(r) => r,
Err(_) => return Vec::new(),
};
reflog
.iter()
.take(limit)
.enumerate()
.map(|(i, entry)| {
let oid = entry.id_new();
let full_hash = oid.to_string();
let short_hash = full_hash[..7.min(full_hash.len())].to_string();
let selector = format!("HEAD@{{{i}}}");
let raw_message = entry.message().unwrap_or("").to_string();
let (action, message) = match raw_message.split_once(": ") {
Some((a, m)) => (a.to_string(), m.to_string()),
None => (raw_message.clone(), raw_message),
};
ReflogEntry {
short_hash,
full_hash,
selector,
action,
message,
}
})
.collect()
}
pub fn commit_changed_files(&self, commit_hash: &str) -> Vec<CommitFileChange> {
let oid = match git2::Oid::from_str(commit_hash) {
Ok(o) => o,
Err(_) => return Vec::new(),
};
let commit = match self.inner.find_commit(oid) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let commit_tree = match commit.tree() {
Ok(t) => t,
Err(_) => return Vec::new(),
};
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let diff =
match self
.inner
.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
{
Ok(d) => d,
Err(_) => return Vec::new(),
};
let mut changes = Vec::new();
for delta in diff.deltas() {
let status = match delta.status() {
git2::Delta::Added => 'A',
git2::Delta::Deleted => 'D',
git2::Delta::Modified => 'M',
git2::Delta::Renamed => 'R',
git2::Delta::Copied => 'C',
_ => 'M',
};
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
changes.push(CommitFileChange { path, status });
}
changes
}
}
fn epoch_to_date(epoch: i64) -> String {
let z = (epoch / 86400) as i32 + 719468;
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i32 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}")
}