use git2::{Repository, Signature, IndexAddOption, StatusOptions};
use std::path::Path;
use crate::error::{Result, ToriiError};
pub struct GitRepo {
pub(crate) repo: Repository,
}
impl GitRepo {
pub fn init<P: AsRef<Path>>(path: P) -> Result<Self> {
let repo = Repository::init(path)?;
Ok(Self { repo })
}
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path_ref = path.as_ref();
let repo = Repository::discover(path_ref)
.map_err(|_| ToriiError::RepositoryNotFound(
path_ref.display().to_string()
))?;
let git_repo = Self { repo };
git_repo.sync_toriignore()?;
Ok(git_repo)
}
pub fn sync_toriignore(&self) -> Result<()> {
let repo_path = self.repo.path().parent().unwrap().to_path_buf();
let toriignore_path = repo_path.join(".toriignore");
let exclude_path = self.repo.path().join("info").join("exclude");
if toriignore_path.exists() {
if let Ok(content) = std::fs::read_to_string(&toriignore_path) {
let header = "# Synced from .toriignore by torii — do not edit manually\n";
let _ = std::fs::write(&exclude_path, format!("{}{}", header, content));
}
}
Ok(())
}
pub fn add_all(&self) -> Result<()> {
self.sync_toriignore()?;
let mut index = self.repo.index()?;
index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
index.write()?;
Ok(())
}
pub fn add<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
let mut index = self.repo.index()?;
for path in paths {
index.add_path(path.as_ref())?;
}
index.write()?;
Ok(())
}
pub fn commit(&self, message: &str) -> Result<()> {
let sig = self.get_signature()?;
let mut index = self.repo.index()?;
let tree_id = index.write_tree()?;
let tree = self.repo.find_tree(tree_id)?;
let parent_commit = match self.repo.head() {
Ok(head) => Some(head.peel_to_commit()?),
Err(_) => None,
};
let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
self.repo.commit(
Some("HEAD"),
&sig,
&sig,
message,
&tree,
&parents,
)?;
Ok(())
}
pub fn commit_amend(&self, message: &str) -> Result<()> {
let sig = self.get_signature()?;
let mut index = self.repo.index()?;
let tree_id = index.write_tree()?;
let tree = self.repo.find_tree(tree_id)?;
let head_commit = self.repo.head()?.peel_to_commit()?;
let parents: Vec<_> = head_commit.parents().collect();
let parent_refs: Vec<_> = parents.iter().collect();
self.repo.commit(
Some("HEAD"),
&sig,
&sig,
message,
&tree,
&parent_refs,
)?;
Ok(())
}
pub fn pull(&self) -> Result<()> {
let mut remote = self.repo.find_remote("origin")?;
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git2::Cred::ssh_key(
username_from_url.unwrap(),
None,
std::path::Path::new(&format!("{}/.ssh/id_ed25519", std::env::var("HOME").unwrap())),
None,
)
});
let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
remote.fetch(&["main", "master"], Some(&mut fetch_options), None)?;
let fetch_head = self.repo.find_reference("FETCH_HEAD")?;
let fetch_commit = self.repo.reference_to_annotated_commit(&fetch_head)?;
let analysis = self.repo.merge_analysis(&[&fetch_commit])?;
if analysis.0.is_up_to_date() {
println!("Already up to date");
} else if analysis.0.is_fast_forward() {
let refname = "refs/heads/main";
let mut reference = self.repo.find_reference(refname)?;
reference.set_target(fetch_commit.id(), "Fast-forward")?;
self.repo.set_head(refname)?;
self.repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
}
Ok(())
}
pub fn push(&self, force: bool) -> Result<()> {
let mut remote = self.repo.find_remote("origin")?;
let branch = self.get_current_branch()?;
let refspec = if force {
format!("+refs/heads/{}:refs/heads/{}", branch, branch)
} else {
format!("refs/heads/{}:refs/heads/{}", branch, branch)
};
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git2::Cred::ssh_key(
username_from_url.unwrap(),
None,
std::path::Path::new(&format!("{}/.ssh/id_ed25519", std::env::var("HOME").unwrap())),
None,
)
});
let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks);
remote.push(&[&refspec], Some(&mut push_options))?;
let repo_path = self.repo.path().parent().unwrap();
let mut tag_args = vec!["push", "origin", "--tags"];
if force {
tag_args.push("--force");
}
let tag_result = std::process::Command::new("git")
.args(&tag_args)
.current_dir(repo_path)
.output();
if let Ok(out) = tag_result {
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
eprintln!("⚠️ Tag push failed: {}", err.trim());
}
}
Ok(())
}
pub fn get_current_branch(&self) -> Result<String> {
let head = self.repo.head()?;
let branch_name = head.shorthand()
.ok_or_else(|| ToriiError::Git(git2::Error::from_str("Could not get branch name")))?;
Ok(branch_name.to_string())
}
pub fn repository(&self) -> &Repository {
&self.repo
}
pub fn status(&self) -> Result<()> {
let mut opts = StatusOptions::new();
opts.include_untracked(true);
let statuses = self.repo.statuses(Some(&mut opts))?;
println!("📊 Repository Status\n");
let branch = self.get_current_branch()?;
println!("Branch: {}", branch);
if let Ok(head) = self.repo.head() {
if let Ok(commit) = head.peel_to_commit() {
let msg = commit.message().unwrap_or("").lines().next().unwrap_or("");
let time = commit.time();
let timestamp = chrono::DateTime::from_timestamp(time.seconds(), 0)
.unwrap_or_default();
let now = chrono::Utc::now();
let duration = now.signed_duration_since(timestamp);
let time_ago = if duration.num_days() > 0 {
format!("{} days ago", duration.num_days())
} else if duration.num_hours() > 0 {
format!("{} hours ago", duration.num_hours())
} else if duration.num_minutes() > 0 {
format!("{} minutes ago", duration.num_minutes())
} else {
"just now".to_string()
};
let short_id = format!("{:.7}", commit.id());
println!("Commit: {} - \"{}\" ({})", short_id, msg, time_ago);
}
}
if let Ok(remote) = self.repo.find_remote("origin") {
if let Some(url) = remote.url() {
let remote_name = url.split('/').last().unwrap_or("origin");
print!("Remote: {}", remote_name.trim_end_matches(".git"));
if let Ok(head) = self.repo.head() {
if let Ok(local_oid) = head.target().ok_or("No target") {
let remote_branch = format!("refs/remotes/origin/{}", branch);
if let Ok(remote_ref) = self.repo.find_reference(&remote_branch) {
if let Ok(remote_oid) = remote_ref.target().ok_or("No target") {
if let Ok((ahead, behind)) = self.repo.graph_ahead_behind(local_oid, remote_oid) {
if ahead > 0 || behind > 0 {
print!(" (");
if ahead > 0 {
print!("{} ahead", ahead);
}
if ahead > 0 && behind > 0 {
print!(", ");
}
if behind > 0 {
print!("{} behind", behind);
}
print!(")");
} else {
print!(" (up to date)");
}
}
}
}
}
}
println!();
}
}
println!();
let mut staged = Vec::new();
let mut unstaged = Vec::new();
let mut untracked = Vec::new();
for entry in statuses.iter() {
let status = entry.status();
let path = entry.path().unwrap_or("unknown").to_string();
if status.is_index_new() || status.is_index_modified() || status.is_index_deleted() {
let prefix = if status.is_index_new() {
"A "
} else if status.is_index_modified() {
"M "
} else {
"D "
};
staged.push(format!("{} {}", prefix, path));
}
if status.is_wt_modified() || status.is_wt_deleted() {
let prefix = if status.is_wt_modified() {
"M "
} else {
"D "
};
unstaged.push(format!("{} {}", prefix, path));
}
if status.is_wt_new() {
untracked.push(format!("?? {}", path));
}
}
let is_clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
if is_clean {
println!("✨ Working tree clean");
} else {
if !staged.is_empty() {
println!("✅ Changes staged for commit:");
for file in &staged {
println!(" {}", file);
}
println!();
}
if !unstaged.is_empty() {
println!("📝 Changes not staged:");
for file in &unstaged {
println!(" {}", file);
}
println!();
}
if !untracked.is_empty() {
println!("📦 Untracked files:");
for file in &untracked {
println!(" {}", file);
}
println!();
}
}
println!("💡 Next steps:");
if is_clean {
println!(" • Start new work: torii branch feature-name -c");
println!(" • Update from remote: torii sync");
println!(" • Create snapshot: torii snapshot create");
} else if !staged.is_empty() && unstaged.is_empty() && untracked.is_empty() {
println!(" • Commit staged changes: torii save -m \"message\"");
println!(" • See staged changes: torii diff --staged");
} else if !unstaged.is_empty() || !untracked.is_empty() {
println!(" • Save all changes: torii save -am \"message\"");
println!(" • See changes: torii diff");
if !staged.is_empty() {
println!(" • Commit only staged: torii save -m \"message\"");
}
}
Ok(())
}
fn get_signature(&self) -> Result<Signature<'_>> {
let config = self.repo.config()?;
let name = config
.get_string("user.name")
.unwrap_or_else(|_| "Torii User".to_string());
let email = config
.get_string("user.email")
.unwrap_or_else(|_| "user@torii.local".to_string());
Ok(Signature::now(&name, &email)?)
}
}