use std::path::{Path, PathBuf};
use std::result::Result as StdResult;
mod nogit;
mod parser;
pub mod recency;
mod subprocess;
mod worktree;
pub use nogit::NoGit;
pub use parser::{parse_diff_name_status, parse_porcelain};
pub use recency::RecencyIndex;
pub use subprocess::{SubprocessGit, max_git_output_size};
pub use worktree::WorktreeManager;
pub type Result<T> = StdResult<T, GitError>;
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("Git binary not found in PATH")]
NotFound,
#[error("Not a git repository: {0}")]
NotARepo(PathBuf),
#[error("Git command timed out after {0}ms")]
Timeout(u64),
#[error("Git command failed: {message}\nstdout: {stdout}\nstderr: {stderr}")]
CommandFailed {
message: String,
stdout: String,
stderr: String,
},
#[error("Failed to parse git output: {0}")]
InvalidOutput(String),
#[error("Git output exceeded configured limit")]
OutputExceededLimit {
limit_bytes: usize,
actual_bytes: usize,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Feature not supported: {0}")]
NotSupported(String),
}
impl GitError {
#[must_use]
pub fn suggested_limit(&self) -> usize {
match self {
GitError::OutputExceededLimit { actual_bytes, .. } => {
let suggested = actual_bytes * 2;
((suggested / (1024 * 1024)) + 1) * (1024 * 1024)
}
_ => 0,
}
}
#[must_use]
pub fn detailed_message(&self) -> String {
match self {
GitError::OutputExceededLimit {
limit_bytes,
actual_bytes,
} => {
let limit_mb = bytes_to_mb(*limit_bytes);
let actual_mb = bytes_to_mb(*actual_bytes);
let suggested = actual_bytes * 2;
let suggested_limit = ((suggested / (1024 * 1024)) + 1) * (1024 * 1024);
let suggested_mb = bytes_to_mb(suggested_limit);
format!(
"Git output exceeded configured limit\n \
Limit: {limit_mb:.1} MB (set via SQRY_GIT_MAX_OUTPUT_SIZE)\n \
Actual: >{actual_mb:.1} MB\n\n \
Suggestions:\n \
- Increase limit: export SQRY_GIT_MAX_OUTPUT_SIZE={suggested_limit} # {suggested_mb:.0} MB\n \
- Investigate large diffs: git diff --stat\n \
- Check for accidentally committed binaries"
)
}
other => format!("{other}"),
}
}
}
#[inline]
#[allow(clippy::cast_precision_loss)] fn bytes_to_mb(bytes: usize) -> f64 {
bytes as f64 / (1024.0 * 1024.0)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ChangeSet {
pub added: Vec<PathBuf>,
pub modified: Vec<PathBuf>,
pub deleted: Vec<PathBuf>,
pub renamed: Vec<(PathBuf, PathBuf)>,
}
impl ChangeSet {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn total(&self) -> usize {
self.added.len() + self.modified.len() + self.deleted.len() + self.renamed.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.total() == 0
}
}
pub trait GitBackend: Send + Sync {
fn is_repo(&self, root: &Path) -> Result<bool>;
fn repo_root(&self, root: &Path) -> Result<PathBuf>;
fn head(&self, root: &Path) -> Result<Option<String>>;
fn uncommitted(
&self,
root: &Path,
include_untracked: bool,
) -> Result<(ChangeSet, Option<String>)>;
fn since(
&self,
root: &Path,
baseline: &str,
rename_similarity: u8,
) -> Result<(ChangeSet, Option<String>)>;
fn capabilities(&self) -> GitCapabilities {
GitCapabilities::default()
}
}
#[derive(Debug, Clone, Default)]
pub struct GitCapabilities {
pub supports_blame: bool,
pub supports_time_travel: bool,
pub supports_history_index: bool,
}
pub struct GitChangeTracker {
backend: Box<dyn GitBackend>,
root: PathBuf,
cached_head: Option<String>,
}
impl GitChangeTracker {
pub fn new(root: &Path) -> Result<Self> {
let backend_type = std::env::var("SQRY_GIT_BACKEND").unwrap_or_else(|_| "auto".to_string());
let backend: Box<dyn GitBackend> = match backend_type.as_str() {
"subprocess" => {
let subprocess = SubprocessGit::new();
if !subprocess.is_repo(root)? {
return Err(GitError::NotARepo(root.to_path_buf()));
}
Box::new(subprocess)
}
"none" => Box::new(NoGit),
_ => {
let subprocess = SubprocessGit::new();
match subprocess.is_repo(root) {
Ok(true) => Box::new(subprocess),
Ok(false) => return Err(GitError::NotARepo(root.to_path_buf())),
Err(GitError::NotFound) => Box::new(NoGit),
Err(e) => return Err(e),
}
}
};
Ok(Self {
backend,
root: root.to_path_buf(),
cached_head: None,
})
}
pub fn detect_changes(
&mut self,
baseline: Option<&str>,
) -> Result<(ChangeSet, Option<String>)> {
let include_untracked = std::env::var("SQRY_GIT_INCLUDE_UNTRACKED")
.ok()
.and_then(|v| v.parse::<u8>().ok())
!= Some(0);
let rename_similarity = std::env::var("SQRY_GIT_RENAME_SIMILARITY")
.ok()
.and_then(|v| v.parse::<u8>().ok())
.map_or(50, |v| v.clamp(0, 100));
if let Some(baseline_sha) = baseline {
let (changes, new_head) =
self.backend
.since(&self.root, baseline_sha, rename_similarity)?;
self.cached_head.clone_from(&new_head);
Ok((changes, new_head))
} else {
let (changes, new_head) = self.backend.uncommitted(&self.root, include_untracked)?;
self.cached_head.clone_from(&new_head);
Ok((changes, new_head))
}
}
pub fn head(&mut self) -> Result<Option<String>> {
if let Some(ref head) = self.cached_head {
return Ok(Some(head.clone()));
}
let head = self.backend.head(&self.root)?;
self.cached_head.clone_from(&head);
Ok(head)
}
pub fn repo_root(&self) -> Result<PathBuf> {
self.backend.repo_root(&self.root)
}
#[must_use]
pub fn capabilities(&self) -> GitCapabilities {
self.backend.capabilities()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_changeset_total() {
let mut changes = ChangeSet::new();
assert_eq!(changes.total(), 0);
assert!(changes.is_empty());
changes.added.push(PathBuf::from("a.rs"));
changes.modified.push(PathBuf::from("b.rs"));
changes.deleted.push(PathBuf::from("c.rs"));
changes
.renamed
.push((PathBuf::from("d.rs"), PathBuf::from("e.rs")));
assert_eq!(changes.total(), 4);
assert!(!changes.is_empty());
}
#[test]
fn test_changeset_equality() {
let mut changes1 = ChangeSet::new();
changes1.added.push(PathBuf::from("a.rs"));
let mut changes2 = ChangeSet::new();
changes2.added.push(PathBuf::from("a.rs"));
assert_eq!(changes1, changes2);
changes2.modified.push(PathBuf::from("b.rs"));
assert_ne!(changes1, changes2);
}
}