const SOFT_DIFF_LIMIT: usize = 15_000;
#[derive(Debug, thiserror::Error)]
pub enum PreflightError {
#[error("Not a git repository")]
NotGitRepo,
#[error("Git command failed: {command}")]
GitCommandFailed {
command: String,
source: std::io::Error,
},
#[error("No staged files")]
NoStagedFiles { unstaged: Vec<UnstagedFile> },
#[error("Working tree clean — nothing to commit")]
WorkingTreeClean,
#[error("Diff too large: {size} chars")]
DiffTooLarge { size: usize },
}
#[derive(Debug, Clone)]
pub struct UnstagedFile {
pub status: String,
pub path: String,
}
#[derive(Debug)]
pub struct PreflightSuccess {
pub diff_content: String,
}
fn is_git_repo() -> bool {
std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.map(|output| {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.trim() == "true"
})
.unwrap_or(false)
}
fn get_staged_files() -> Result<Vec<String>, std::io::Error> {
let output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only"])
.output()?;
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(String::from)
.collect())
}
fn get_unstaged_files() -> Result<Vec<UnstagedFile>, std::io::Error> {
let output = std::process::Command::new("git")
.args(["status", "-s"])
.output()?;
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let bytes = line.as_bytes();
if bytes.len() < 4 {
return None; }
let c1 = bytes[0] as char;
let c2 = bytes[1] as char;
let path = line[3..].to_string();
if (c1 == ' ' && c2 == ' ') || path.is_empty() {
return None;
}
Some(UnstagedFile {
status: format!("{}{}", c1, c2),
path,
})
})
.collect())
}
fn get_diff_content() -> Result<String, std::io::Error> {
let output = std::process::Command::new("git")
.args(["diff", "--cached"])
.output()?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn is_working_tree_clean() -> Result<bool, std::io::Error> {
let output = std::process::Command::new("git")
.args(["status", "--porcelain"])
.output()?;
let clean = String::from_utf8_lossy(&output.stdout)
.lines()
.all(|line| line.trim().is_empty());
Ok(clean)
}
pub fn run() -> Result<PreflightSuccess, PreflightError> {
if !is_git_repo() {
return Err(PreflightError::NotGitRepo);
}
if is_working_tree_clean().unwrap_or(false) {
return Err(PreflightError::WorkingTreeClean);
}
let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
command: "git diff --cached --name-only".into(),
source: e,
})?;
if staged.is_empty() {
let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
command: "git status -s".into(),
source: e,
})?;
return Err(PreflightError::NoStagedFiles { unstaged });
}
let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
command: "git diff --cached".into(),
source: e,
})?;
if diff_content.len() > SOFT_DIFF_LIMIT {
return Err(PreflightError::DiffTooLarge {
size: diff_content.len(),
});
}
Ok(PreflightSuccess { diff_content })
}
pub fn run_with_diff_bypass() -> Result<PreflightSuccess, PreflightError> {
if !is_git_repo() {
return Err(PreflightError::NotGitRepo);
}
if is_working_tree_clean().unwrap_or(false) {
return Err(PreflightError::WorkingTreeClean);
}
let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
command: "git diff --cached --name-only".into(),
source: e,
})?;
if staged.is_empty() {
let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
command: "git status -s".into(),
source: e,
})?;
return Err(PreflightError::NoStagedFiles { unstaged });
}
let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
command: "git diff --cached".into(),
source: e,
})?;
Ok(PreflightSuccess { diff_content })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unstaged_file_parse_status_m() {
let file = UnstagedFile {
status: "M".to_string(),
path: "src/main.rs".to_string(),
};
assert_eq!(file.status, "M");
assert_eq!(file.path, "src/main.rs");
}
#[test]
fn test_unstaged_file_parse_status_uu() {
let file = UnstagedFile {
status: "??".to_string(),
path: ".env.example".to_string(),
};
assert_eq!(file.status, "??");
assert_eq!(file.path, ".env.example");
}
#[test]
fn test_preflight_error_display() {
let err = PreflightError::NotGitRepo;
assert_eq!(err.to_string(), "Not a git repository");
}
#[test]
fn test_diff_too_large_error_display() {
let err = PreflightError::DiffTooLarge { size: 23450 };
assert_eq!(err.to_string(), "Diff too large: 23450 chars");
}
}