1pub use crate::filter::{filter_staged_files, FilterMode};
2
3const SOFT_DIFF_LIMIT: usize = 15_000;
4
5#[derive(Debug, thiserror::Error)]
6pub enum PreflightError {
7 #[error("Not a git repository")]
8 NotGitRepo,
9 #[error("Git command failed: {command}")]
10 GitCommandFailed {
11 command: String,
12 source: std::io::Error,
13 },
14 #[error("No staged files")]
15 NoStagedFiles { unstaged: Vec<UnstagedFile> },
16 #[error("Working tree clean — nothing to commit")]
17 WorkingTreeClean,
18 #[error("Diff too large: {size} chars")]
19 DiffTooLarge { size: usize },
20}
21
22#[derive(Debug, Clone)]
23pub struct UnstagedFile {
24 pub status: String,
25 pub path: String,
26}
27
28#[derive(Debug)]
29pub struct PreflightSuccess {
30 pub diff_content: String,
31 pub is_static_message: bool,
32}
33
34fn is_git_repo() -> bool {
35 std::process::Command::new("git")
36 .args(["rev-parse", "--is-inside-work-tree"])
37 .output()
38 .map(|output| {
39 let stdout = String::from_utf8_lossy(&output.stdout);
40 stdout.trim() == "true"
41 })
42 .unwrap_or(false)
43}
44
45fn get_staged_files() -> Result<Vec<String>, std::io::Error> {
46 let output = std::process::Command::new("git")
47 .args(["diff", "--cached", "--name-only"])
48 .output()?;
49 Ok(String::from_utf8_lossy(&output.stdout)
50 .lines()
51 .map(String::from)
52 .collect())
53}
54
55fn get_unstaged_files() -> Result<Vec<UnstagedFile>, std::io::Error> {
56 let output = std::process::Command::new("git")
57 .args(["status", "-s"])
58 .output()?;
59 Ok(String::from_utf8_lossy(&output.stdout)
60 .lines()
61 .filter_map(|line| {
62 let bytes = line.as_bytes();
63 if bytes.len() < 4 {
64 return None; }
66 let c1 = bytes[0] as char;
70 let c2 = bytes[1] as char;
71 let path = line[3..].to_string();
72 if (c1 == ' ' && c2 == ' ') || path.is_empty() {
74 return None;
75 }
76 Some(UnstagedFile {
77 status: format!("{}{}", c1, c2),
78 path,
79 })
80 })
81 .collect())
82}
83
84fn get_diff_content() -> Result<String, std::io::Error> {
85 let output = std::process::Command::new("git")
86 .args(["diff", "--cached"])
87 .output()?;
88 Ok(String::from_utf8_lossy(&output.stdout).to_string())
89}
90
91fn is_working_tree_clean() -> Result<bool, std::io::Error> {
92 let output = std::process::Command::new("git")
93 .args(["status", "--porcelain"])
94 .output()?;
95 let clean = String::from_utf8_lossy(&output.stdout)
96 .lines()
97 .all(|line| line.trim().is_empty());
98 Ok(clean)
99}
100
101pub fn run() -> Result<PreflightSuccess, PreflightError> {
106 run_with_filter(FilterMode::Smart)
107}
108
109pub fn run_with_filter(mode: FilterMode) -> Result<PreflightSuccess, PreflightError> {
111 if !is_git_repo() {
112 return Err(PreflightError::NotGitRepo);
113 }
114
115 if is_working_tree_clean().map_err(|e| PreflightError::GitCommandFailed {
116 command: "git status --porcelain".into(),
117 source: e,
118 })? {
119 return Err(PreflightError::WorkingTreeClean);
120 }
121
122 let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
123 command: "git diff --cached --name-only".into(),
124 source: e,
125 })?;
126
127 if staged.is_empty() {
128 let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
129 command: "git status -s".into(),
130 source: e,
131 })?;
132 return Err(PreflightError::NoStagedFiles { unstaged });
133 }
134
135 let filter_result = filter_staged_files(mode).map_err(|e| {
137 PreflightError::GitCommandFailed {
138 command: "git diff --cached --numstat".into(),
139 source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
140 }
141 })?;
142
143 let diff_content = if filter_result.excluded.is_empty() {
144 get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
145 command: "git diff --cached".into(),
146 source: e,
147 })?
148 } else {
149 let exclude_args = crate::filter::build_git_exclude_args(&filter_result.excluded);
150 get_filtered_diff_content(&exclude_args).map_err(|e| PreflightError::GitCommandFailed {
151 command: "git diff --cached :(exclude)".into(),
152 source: e,
153 })?
154 };
155
156 if filter_result.all_excluded && diff_content.trim().is_empty() {
158 return Ok(PreflightSuccess {
159 diff_content: "chore: update dependencies".to_string(),
160 is_static_message: true,
161 });
162 }
163
164 if diff_content.len() > SOFT_DIFF_LIMIT {
166 return Err(PreflightError::DiffTooLarge {
167 size: diff_content.len(),
168 });
169 }
170
171 Ok(PreflightSuccess {
172 diff_content,
173 is_static_message: false,
174 })
175}
176
177fn get_filtered_diff_content(exclude_args: &[String]) -> Result<String, std::io::Error> {
179 let mut cmd = std::process::Command::new("git");
180 cmd.arg("diff").arg("--cached");
181 for arg in exclude_args {
182 cmd.arg(arg);
183 }
184 let output = cmd.output()?;
185 Ok(String::from_utf8_lossy(&output.stdout).to_string())
186}
187
188pub fn run_with_diff_bypass() -> Result<PreflightSuccess, PreflightError> {
191 match run_with_filter(FilterMode::NoFilter) {
193 Ok(s) => Ok(s),
194 Err(PreflightError::DiffTooLarge { .. }) => {
195 let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
196 command: "git diff --cached".into(),
197 source: e,
198 })?;
199 Ok(PreflightSuccess { diff_content, is_static_message: false })
200 }
201 Err(e) => Err(e),
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn test_unstaged_file_parse_status_m() {
211 let file = UnstagedFile {
212 status: "M".to_string(),
213 path: "src/main.rs".to_string(),
214 };
215 assert_eq!(file.status, "M");
216 assert_eq!(file.path, "src/main.rs");
217 }
218
219 #[test]
220 fn test_unstaged_file_parse_status_uu() {
221 let file = UnstagedFile {
222 status: "??".to_string(),
223 path: ".env.example".to_string(),
224 };
225 assert_eq!(file.status, "??");
226 assert_eq!(file.path, ".env.example");
227 }
228
229 #[test]
230 fn test_preflight_error_display() {
231 let err = PreflightError::NotGitRepo;
232 assert_eq!(err.to_string(), "Not a git repository");
233 }
234
235 #[test]
236 fn test_diff_too_large_error_display() {
237 let err = PreflightError::DiffTooLarge { size: 23450 };
238 assert_eq!(err.to_string(), "Diff too large: 23450 chars");
239 }
240}