use git2::{Repository, Signature, IndexAddOption, StatusOptions};
use std::path::{Path, PathBuf};
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 initial = crate::config::ToriiConfig::load_global()
.map(|c| c.git.default_branch)
.unwrap_or_else(|_| "main".to_string());
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head(&initial);
let repo = Repository::init_opts(path, &opts)?;
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()
.ok_or_else(|| crate::error::ToriiError::RepoState("git directory has no parent (bare repo?)".to_string()))?
.to_path_buf();
let public_path = repo_path.join(".toriignore");
let local_path = repo_path.join(".toriignore.local");
let exclude_path = self.repo.path().join("info").join("exclude");
let mut buf = String::from(
"# Synced from .toriignore by torii — do not edit manually\n\
# Local-only rules — never commit\n\
.toriignore.local\n",
);
if public_path.exists() {
if let Ok(content) = std::fs::read_to_string(&public_path) {
buf.push_str(&content);
if !buf.ends_with('\n') { buf.push('\n'); }
}
}
if local_path.exists() {
if let Ok(content) = std::fs::read_to_string(&local_path) {
buf.push_str("# ─── from .toriignore.local ───\n");
buf.push_str(&content);
}
}
std::fs::write(&exclude_path, buf)
.map_err(|e| ToriiError::Fs(format!("write {}: {}", exclude_path.display(), e)))?;
Ok(())
}
pub fn add_all(&self) -> Result<()> {
self.sync_toriignore()?;
let mut index = self.repo.index()?;
let mut skipped_torii = false;
let cb = &mut |path: &Path, _matched: &[u8]| -> i32 {
let s = path.to_string_lossy();
if s == ".torii" || s.starts_with(".torii/") || s.starts_with(".torii\\") {
skipped_torii = true;
1 } else {
0 }
};
index.add_all(["*"].iter(), IndexAddOption::DEFAULT, Some(cb as &mut git2::IndexMatchedPath<'_>))?;
index.write()?;
if skipped_torii {
eprintln!("ℹ Skipped `.torii/` from staging (reserved for torii internal state). \
Pass paths explicitly if you really want to stage something inside it.");
}
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 unstage<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
match self.repo.head() {
Ok(head) => {
let head_obj = head.peel(git2::ObjectType::Commit)?;
let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_ref()).collect();
self.repo.reset_default(Some(&head_obj), path_refs.iter())?;
}
Err(_) => {
let mut index = self.repo.index()?;
for path in paths {
let _ = index.remove_path(path.as_ref());
}
index.write()?;
}
}
Ok(())
}
pub fn unstage_all(&self) -> Result<()> {
let index = self.repo.index()?;
let paths: Vec<PathBuf> = index
.iter()
.filter_map(|e| std::str::from_utf8(&e.path).ok().map(PathBuf::from))
.collect();
if paths.is_empty() {
return Ok(());
}
self.unstage(&paths)
}
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();
commit_inner(&self.repo, Some("HEAD"), &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_ref = self.repo.head()?;
let head_oid = head_ref.target()
.ok_or_else(|| ToriiError::RepoState("HEAD has no target".to_string()))?;
let head_commit = self.repo.find_commit(head_oid)?;
let parents: Vec<_> = head_commit.parents().collect();
let parent_refs: Vec<_> = parents.iter().collect();
let new_oid = commit_inner(&self.repo, None, &sig, message, &tree, &parent_refs)?;
if head_ref.is_branch() {
if let Some(refname) = head_ref.name() {
self.repo.reference(refname, new_oid, true, "amend")?;
}
} else {
self.repo.set_head_detached(new_oid)?;
}
Ok(())
}
pub(crate) fn auth_callbacks_for<'a>(url: &str) -> git2::RemoteCallbacks<'a> {
let url_owned = url.to_string();
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(move |cb_url, username_from_url, allowed_types| {
let effective_url = if url_owned.is_empty() { cb_url } else { &url_owned };
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
let username = username_from_url.unwrap_or("git");
let home = dirs::home_dir().unwrap_or_default();
let ed25519 = home.join(".ssh").join("id_ed25519");
let rsa = home.join(".ssh").join("id_rsa");
if ed25519.exists() {
return git2::Cred::ssh_key(username, None, &ed25519, None);
} else if rsa.exists() {
return git2::Cred::ssh_key(username, None, &rsa, None);
} else {
return git2::Cred::ssh_key_from_agent(username);
}
}
if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
let provider = if effective_url.contains("github.com") {
"github"
} else if effective_url.contains("gitlab.com") {
"gitlab"
} else if effective_url.contains("codeberg.org") {
"codeberg"
} else {
"gitea"
};
if let Some(token) = crate::auth::resolve_token(provider, ".").value {
return git2::Cred::userpass_plaintext("oauth2", &token);
}
}
git2::Cred::default()
});
callbacks
}
pub(crate) fn attach_fetch_progress<'a>(callbacks: &mut git2::RemoteCallbacks<'a>) {
use std::cell::RefCell;
use std::io::Write;
use std::time::Instant;
let last_print = RefCell::new(Instant::now());
callbacks.transfer_progress(move |stats| {
let mut last = last_print.borrow_mut();
let total = stats.total_objects();
let recv = stats.received_objects();
let idx = stats.indexed_objects();
let total_deltas = stats.total_deltas();
let idx_deltas = stats.indexed_deltas();
let receiving_done = total > 0 && recv == total && idx == total;
let deltas_done = total_deltas == 0 || idx_deltas == total_deltas;
let done = receiving_done && deltas_done;
if !done && last.elapsed().as_millis() < 100 {
return true;
}
*last = Instant::now();
let mb = stats.received_bytes() as f64 / (1024.0 * 1024.0);
if total_deltas > 0 && recv == total {
let pct = if total_deltas > 0 { idx_deltas * 100 / total_deltas } else { 100 };
print!(
"\r🧩 Resolving deltas {pct}% {idx_deltas}/{total_deltas} "
);
} else {
let pct = if total > 0 { recv * 100 / total } else { 0 };
print!(
"\r📥 {pct}% {recv}/{total} objects {idx} indexed {mb:.1} MB ",
);
}
std::io::stdout().flush().ok();
if done {
println!();
}
true
});
callbacks.sideband_progress(|line| {
std::io::stderr().write_all(line).ok();
true
});
}
pub(crate) fn attach_push_progress<'a>(callbacks: &mut git2::RemoteCallbacks<'a>) {
use std::cell::RefCell;
use std::io::Write;
use std::time::Instant;
let last_print = RefCell::new(Instant::now());
callbacks.push_transfer_progress(move |current, total, bytes| {
let mut last = last_print.borrow_mut();
let done = total > 0 && current == total;
if !done && last.elapsed().as_millis() < 100 {
return;
}
*last = Instant::now();
let pct = if total > 0 { current * 100 / total } else { 0 };
let mb = bytes as f64 / (1024.0 * 1024.0);
print!("\r📤 {pct}% {current}/{total} objects {mb:.1} MB ");
std::io::stdout().flush().ok();
if done {
println!();
}
});
callbacks.sideband_progress(|line| {
std::io::stderr().write_all(line).ok();
true
});
}
pub fn pull(&self) -> Result<()> {
let branch = self.get_current_branch()?;
let mut remote = self.repo.find_remote("origin")?;
let remote_url = remote.url().unwrap_or("").to_string();
let mut callbacks = Self::auth_callbacks_for(&remote_url);
Self::attach_fetch_progress(&mut callbacks);
let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
remote.fetch(&[&branch], Some(&mut fetch_options), None)?;
let fetch_head_path = self.repo.path().join("FETCH_HEAD");
if fetch_head_path.metadata().map(|m| m.len() == 0).unwrap_or(true) {
return Ok(());
}
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() {
return Ok(());
}
if analysis.0.is_fast_forward() {
let refname = format!("refs/heads/{}", branch);
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()))?;
return Ok(());
}
Err(ToriiError::RepoState(format!(
"Pull not fast-forward on '{}'. Local and remote diverged. Use 'torii sync {} --merge' or 'torii sync {} --rebase' to integrate.",
branch, branch, branch
)))
}
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 remote_url = remote.url().unwrap_or("").to_string();
let mut callbacks = Self::auth_callbacks_for(&remote_url);
let rejections: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let acknowledged: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let rejections_cb = rejections.clone();
let acknowledged_cb = acknowledged.clone();
callbacks.push_update_reference(move |refname, status| {
acknowledged_cb.lock().unwrap().push(refname.to_string());
if let Some(msg) = status {
rejections_cb
.lock()
.unwrap()
.push((refname.to_string(), msg.to_string()));
}
Ok(())
});
Self::attach_push_progress(&mut callbacks);
let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks);
remote.push(&[&refspec], Some(&mut push_options))?;
let rejected = rejections.lock().unwrap();
if !rejected.is_empty() {
let detail = rejected
.iter()
.map(|(r, m)| format!("{} → {}", r, m))
.collect::<Vec<_>>()
.join("; ");
return Err(ToriiError::Git(git2::Error::from_str(&format!(
"push rejected by remote: {}",
detail
))));
}
let acks = acknowledged.lock().unwrap();
if acks.is_empty() {
return Err(ToriiError::Git(git2::Error::from_str(
"push completed without server acknowledging any refs — \
transport may have failed silently. Check network / auth and retry. \
(Common with very large pushes over SSH; try HTTPS with a token.)"
)));
}
self.push_all_tags("origin", force)?;
Ok(())
}
pub fn push_all_tags(&self, remote_name: &str, force: bool) -> Result<()> {
let local_tags = self.repo.tag_names(None)?;
if local_tags.is_empty() {
return Ok(());
}
let local: std::collections::HashMap<String, git2::Oid> = local_tags.iter()
.flatten()
.filter_map(|t| {
let refname = format!("refs/tags/{}", t);
self.repo.refname_to_id(&refname).ok().map(|oid| (t.to_string(), oid))
})
.collect();
let mut remote = self.repo.find_remote(remote_name)?;
let remote_url = remote.url().unwrap_or("").to_string();
let remote_tags: std::collections::HashMap<String, git2::Oid> = {
let callbacks = Self::auth_callbacks_for(&remote_url);
remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?;
let list = remote.list()?;
let map = list.iter()
.filter_map(|h| {
let name = h.name();
name.strip_prefix("refs/tags/")
.filter(|n| !n.ends_with("^{}"))
.map(|n| (n.to_string(), h.oid()))
})
.collect::<std::collections::HashMap<_, _>>();
remote.disconnect()?;
map
};
let refspecs: Vec<String> = local.iter()
.filter(|(name, oid)| remote_tags.get(*name) != Some(oid))
.map(|(t, _)| {
let r = format!("refs/tags/{}:refs/tags/{}", t, t);
if force { format!("+{}", r) } else { r }
})
.collect();
if refspecs.is_empty() {
return Ok(());
}
let refspec_refs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
let callbacks = Self::auth_callbacks_for(&remote_url);
let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks);
remote.push(&refspec_refs, Some(&mut push_options))
.map_err(ToriiError::Git)?;
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(crate) fn repository(&self) -> &Repository {
&self.repo
}
pub fn workdir(&self) -> Option<&Path> {
self.repo.workdir()
}
pub fn remotes(&self) -> Result<Vec<(String, Option<String>)>> {
let names = self.repo.remotes()?;
let mut out = Vec::new();
for name in names.iter().flatten() {
let url = self
.repo
.find_remote(name)
.ok()
.and_then(|r| r.url().map(String::from));
out.push((name.to_string(), url));
}
Ok(out)
}
pub fn remote_exists(&self, name: &str) -> bool {
self.repo.find_remote(name).is_ok()
}
pub fn remote_url(&self, name: &str) -> Result<Option<String>> {
let remote = self.repo.find_remote(name)?;
Ok(remote.url().map(String::from))
}
pub fn remote_add(&self, name: &str, url: &str) -> Result<()> {
self.repo.remote(name, url)?;
Ok(())
}
pub fn remote_set_url(&self, name: &str, url: &str) -> Result<()> {
self.repo.remote_set_url(name, url)?;
Ok(())
}
pub fn remote_delete(&self, name: &str) -> Result<()> {
self.repo.remote_delete(name)?;
Ok(())
}
pub fn status(&self) -> Result<RepoStatus> {
let mut opts = StatusOptions::new();
opts.include_untracked(true);
let statuses = self.repo.statuses(Some(&mut opts))?;
let branch = self.get_current_branch()?;
let head = self.repo.head().ok()
.and_then(|h| h.peel_to_commit().ok())
.map(|commit| HeadCommitInfo {
short_id: format!("{:.7}", commit.id()),
summary: commit.message().unwrap_or("").lines().next().unwrap_or("").to_string(),
seconds_since_epoch: commit.time().seconds(),
});
let remote = self.repo.find_remote("origin").ok().and_then(|remote| {
let url = remote.url()?;
let name = url.split('/').last().unwrap_or("origin")
.trim_end_matches(".git").to_string();
let ahead_behind = self.repo.head().ok()
.and_then(|h| h.target())
.and_then(|local_oid| {
let remote_ref = self.repo
.find_reference(&format!("refs/remotes/origin/{}", branch)).ok()?;
let remote_oid = remote_ref.target()?;
self.repo.graph_ahead_behind(local_oid, remote_oid).ok()
});
Some(RemoteStatusInfo { name, ahead_behind })
});
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 kind = if status.is_index_new() {
ChangeKind::Added
} else if status.is_index_modified() {
ChangeKind::Modified
} else {
ChangeKind::Deleted
};
staged.push(StatusEntry { kind, path: path.clone() });
}
if status.is_wt_modified() || status.is_wt_deleted() {
let kind = if status.is_wt_modified() {
ChangeKind::Modified
} else {
ChangeKind::Deleted
};
unstaged.push(StatusEntry { kind, path: path.clone() });
}
if status.is_wt_new() {
untracked.push(path);
}
}
Ok(RepoStatus { branch, head, remote, staged, unstaged, untracked })
}
fn get_signature(&self) -> Result<Signature<'static>> {
resolve_signature(&self.repo)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum ChangeKind {
Added,
Modified,
Deleted,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StatusEntry {
pub kind: ChangeKind,
pub path: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct HeadCommitInfo {
pub short_id: String,
pub summary: String,
pub seconds_since_epoch: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RemoteStatusInfo {
pub name: String,
pub ahead_behind: Option<(usize, usize)>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RepoStatus {
pub branch: String,
pub head: Option<HeadCommitInfo>,
pub remote: Option<RemoteStatusInfo>,
pub staged: Vec<StatusEntry>,
pub unstaged: Vec<StatusEntry>,
pub untracked: Vec<String>,
}
impl RepoStatus {
pub fn is_clean(&self) -> bool {
self.staged.is_empty() && self.unstaged.is_empty() && self.untracked.is_empty()
}
}
#[doc(hidden)]
pub fn resolve_signature(repo: &git2::Repository) -> Result<Signature<'static>> {
let tc = repo.workdir()
.and_then(|wd| crate::config::ToriiConfig::load_local(wd).ok())
.unwrap_or_else(|| crate::config::ToriiConfig::load_global().unwrap_or_default());
let name = tc
.user
.name
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
repo.config()
.ok()
.and_then(|c| c.get_string("user.name").ok())
.filter(|s| !s.trim().is_empty())
})
.ok_or_else(|| {
crate::error::ToriiError::InvalidConfig(
"user.name not configured. Set it with:\n \
torii config set user.name \"Your Name\""
.to_string(),
)
})?;
let email = tc
.user
.email
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
repo.config()
.ok()
.and_then(|c| c.get_string("user.email").ok())
.filter(|s| !s.trim().is_empty())
})
.ok_or_else(|| {
crate::error::ToriiError::InvalidConfig(
"user.email not configured. Set it with:\n \
torii config set user.email \"you@example.com\""
.to_string(),
)
})?;
Ok(Signature::now(&name, &email)?)
}
#[doc(hidden)]
pub fn commit_inner(
repo: &git2::Repository,
update_ref: Option<&str>,
sig: &Signature,
message: &str,
tree: &git2::Tree,
parents: &[&git2::Commit],
) -> Result<git2::Oid> {
commit_inner_split(repo, update_ref, sig, sig, message, tree, parents)
}
pub(crate) fn commit_inner_split(
repo: &git2::Repository,
update_ref: Option<&str>,
author: &Signature,
committer: &Signature,
message: &str,
tree: &git2::Tree,
parents: &[&git2::Commit],
) -> Result<git2::Oid> {
let tc = repo.workdir()
.and_then(|wd| crate::config::ToriiConfig::load_local(wd).ok())
.unwrap_or_else(|| crate::config::ToriiConfig::load_global().unwrap_or_default());
let should_sign = match std::env::var("TORII_SIGN_OVERRIDE").ok().as_deref() {
Some("true") => true,
Some("false") => false,
_ => tc.git.sign_commits,
};
if !should_sign {
return Ok(repo.commit(update_ref, author, committer, message, tree, parents)?);
}
let key = tc.git.gpg_key.as_deref()
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| ToriiError::InvalidConfig(
"git.sign_commits = true but git.gpg_key is not set. Configure with:\n \
torii config set git.gpg_key <YOUR-KEY-ID>".to_string()
))?;
let buffer = repo.commit_create_buffer(author, committer, message, tree, parents)?;
let buffer_str = std::str::from_utf8(&buffer)
.map_err(|e| ToriiError::RepoState(format!(
"commit buffer not valid UTF-8 (cannot GPG-sign): {}", e
)))?;
let signature = crate::gpg::sign_blob(
&buffer,
key,
tc.git.gpg_program.as_deref(),
)?;
let new_oid = repo.commit_signed(buffer_str, &signature, Some("gpgsig"))?;
if let Some(name) = update_ref {
let target_ref = if name == "HEAD" {
match repo.head() {
Ok(h) => h.name().map(String::from).unwrap_or_else(|| "refs/heads/main".to_string()),
Err(_) => format!("refs/heads/{}", tc.git.default_branch),
}
} else {
name.to_string()
};
repo.reference(&target_ref, new_oid, true, "torii signed commit")?;
}
Ok(new_oid)
}
#[cfg(test)]
mod add_all_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn add_all_skips_dot_torii_directory() {
let tmp = TempDir::new().unwrap();
let repo_path = tmp.path();
let _ = git2::Repository::init(repo_path).unwrap();
let gitorii = GitRepo::open(repo_path).unwrap();
fs::write(repo_path.join("README.md"), "hello").unwrap();
fs::create_dir_all(repo_path.join(".torii/snapshots/x")).unwrap();
fs::write(repo_path.join(".torii/snapshots/x/big.bin"), vec![0u8; 1024]).unwrap();
fs::write(repo_path.join(".torii/config.json"), "{}").unwrap();
gitorii.add_all().unwrap();
let index = gitorii.repo.index().unwrap();
let staged: Vec<String> = index.iter()
.map(|e| String::from_utf8_lossy(&e.path).into_owned())
.collect();
assert!(staged.iter().any(|p| p == "README.md"),
"README.md should be staged, got: {:?}", staged);
assert!(!staged.iter().any(|p| p.starts_with(".torii")),
".torii/* must not be staged by add_all, got: {:?}", staged);
}
}