1const SOFT_DIFF_LIMIT: usize = 15_000;
2
3#[derive(Debug, thiserror::Error)]
4pub enum PreflightError {
5 #[error("Not a git repository")]
6 NotGitRepo,
7 #[error("Git command failed: {command}")]
8 GitCommandFailed {
9 command: String,
10 source: std::io::Error,
11 },
12 #[error("No staged files")]
13 NoStagedFiles { unstaged: Vec<UnstagedFile> },
14 #[error("Working tree clean — nothing to commit")]
15 WorkingTreeClean,
16 #[error("Diff too large: {size} chars")]
17 DiffTooLarge { size: usize },
18}
19
20#[derive(Debug, Clone)]
21pub struct UnstagedFile {
22 pub status: String,
23 pub path: String,
24}
25
26#[derive(Debug)]
27pub struct PreflightSuccess {
28 pub diff_content: String,
29}
30
31fn is_git_repo() -> bool {
32 std::process::Command::new("git")
33 .args(["rev-parse", "--is-inside-work-tree"])
34 .output()
35 .map(|output| {
36 let stdout = String::from_utf8_lossy(&output.stdout);
37 stdout.trim() == "true"
38 })
39 .unwrap_or(false)
40}
41
42fn get_staged_files() -> Result<Vec<String>, std::io::Error> {
43 let output = std::process::Command::new("git")
44 .args(["diff", "--cached", "--name-only"])
45 .output()?;
46 Ok(String::from_utf8_lossy(&output.stdout)
47 .lines()
48 .map(String::from)
49 .collect())
50}
51
52fn get_unstaged_files() -> Result<Vec<UnstagedFile>, std::io::Error> {
53 let output = std::process::Command::new("git")
54 .args(["status", "-s"])
55 .output()?;
56 Ok(String::from_utf8_lossy(&output.stdout)
57 .lines()
58 .filter_map(|line| {
59 let bytes = line.as_bytes();
60 if bytes.len() < 4 {
61 return None; }
63 let c1 = bytes[0] as char;
67 let c2 = bytes[1] as char;
68 let path = line[3..].to_string();
69 if (c1 == ' ' && c2 == ' ') || path.is_empty() {
71 return None;
72 }
73 Some(UnstagedFile {
74 status: format!("{}{}", c1, c2),
75 path,
76 })
77 })
78 .collect())
79}
80
81fn get_diff_content() -> Result<String, std::io::Error> {
82 let output = std::process::Command::new("git")
83 .args(["diff", "--cached"])
84 .output()?;
85 Ok(String::from_utf8_lossy(&output.stdout).to_string())
86}
87
88fn is_working_tree_clean() -> Result<bool, std::io::Error> {
89 let output = std::process::Command::new("git")
90 .args(["status", "--porcelain"])
91 .output()?;
92 let clean = String::from_utf8_lossy(&output.stdout)
93 .lines()
94 .all(|line| line.trim().is_empty());
95 Ok(clean)
96}
97
98pub fn run() -> Result<PreflightSuccess, PreflightError> {
103 if !is_git_repo() {
104 return Err(PreflightError::NotGitRepo);
105 }
106
107 if is_working_tree_clean().map_err(|e| PreflightError::GitCommandFailed {
108 command: "git status --porcelain".into(),
109 source: e,
110 })? {
111 return Err(PreflightError::WorkingTreeClean);
112 }
113
114 let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
115 command: "git diff --cached --name-only".into(),
116 source: e,
117 })?;
118
119 if staged.is_empty() {
120 let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
121 command: "git status -s".into(),
122 source: e,
123 })?;
124 return Err(PreflightError::NoStagedFiles { unstaged });
125 }
126
127 let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
128 command: "git diff --cached".into(),
129 source: e,
130 })?;
131
132 if diff_content.len() > SOFT_DIFF_LIMIT {
133 return Err(PreflightError::DiffTooLarge {
134 size: diff_content.len(),
135 });
136 }
137
138 Ok(PreflightSuccess { diff_content })
139}
140
141pub fn run_with_diff_bypass() -> Result<PreflightSuccess, PreflightError> {
144 if !is_git_repo() {
145 return Err(PreflightError::NotGitRepo);
146 }
147
148 if is_working_tree_clean().map_err(|e| PreflightError::GitCommandFailed {
149 command: "git status --porcelain".into(),
150 source: e,
151 })? {
152 return Err(PreflightError::WorkingTreeClean);
153 }
154
155 let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
156 command: "git diff --cached --name-only".into(),
157 source: e,
158 })?;
159 if staged.is_empty() {
160 let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
161 command: "git status -s".into(),
162 source: e,
163 })?;
164 return Err(PreflightError::NoStagedFiles { unstaged });
165 }
166 let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
167 command: "git diff --cached".into(),
168 source: e,
169 })?;
170 Ok(PreflightSuccess { diff_content })
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_unstaged_file_parse_status_m() {
179 let file = UnstagedFile {
180 status: "M".to_string(),
181 path: "src/main.rs".to_string(),
182 };
183 assert_eq!(file.status, "M");
184 assert_eq!(file.path, "src/main.rs");
185 }
186
187 #[test]
188 fn test_unstaged_file_parse_status_uu() {
189 let file = UnstagedFile {
190 status: "??".to_string(),
191 path: ".env.example".to_string(),
192 };
193 assert_eq!(file.status, "??");
194 assert_eq!(file.path, ".env.example");
195 }
196
197 #[test]
198 fn test_preflight_error_display() {
199 let err = PreflightError::NotGitRepo;
200 assert_eq!(err.to_string(), "Not a git repository");
201 }
202
203 #[test]
204 fn test_diff_too_large_error_display() {
205 let err = PreflightError::DiffTooLarge { size: 23450 };
206 assert_eq!(err.to_string(), "Diff too large: 23450 chars");
207 }
208}