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().unwrap_or(false) {
108 return Err(PreflightError::WorkingTreeClean);
109 }
110
111 let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
112 command: "git diff --cached --name-only".into(),
113 source: e,
114 })?;
115
116 if staged.is_empty() {
117 let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
118 command: "git status -s".into(),
119 source: e,
120 })?;
121 return Err(PreflightError::NoStagedFiles { unstaged });
122 }
123
124 let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
125 command: "git diff --cached".into(),
126 source: e,
127 })?;
128
129 if diff_content.len() > SOFT_DIFF_LIMIT {
130 return Err(PreflightError::DiffTooLarge {
131 size: diff_content.len(),
132 });
133 }
134
135 Ok(PreflightSuccess { diff_content })
136}
137
138pub fn run_with_diff_bypass() -> Result<PreflightSuccess, PreflightError> {
141 if !is_git_repo() {
142 return Err(PreflightError::NotGitRepo);
143 }
144
145 if is_working_tree_clean().unwrap_or(false) {
146 return Err(PreflightError::WorkingTreeClean);
147 }
148
149 let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
150 command: "git diff --cached --name-only".into(),
151 source: e,
152 })?;
153 if staged.is_empty() {
154 let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
155 command: "git status -s".into(),
156 source: e,
157 })?;
158 return Err(PreflightError::NoStagedFiles { unstaged });
159 }
160 let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
161 command: "git diff --cached".into(),
162 source: e,
163 })?;
164 Ok(PreflightSuccess { diff_content })
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn test_unstaged_file_parse_status_M() {
173 let file = UnstagedFile {
174 status: "M".to_string(),
175 path: "src/main.rs".to_string(),
176 };
177 assert_eq!(file.status, "M");
178 assert_eq!(file.path, "src/main.rs");
179 }
180
181 #[test]
182 fn test_unstaged_file_parse_status_UU() {
183 let file = UnstagedFile {
184 status: "??".to_string(),
185 path: ".env.example".to_string(),
186 };
187 assert_eq!(file.status, "??");
188 assert_eq!(file.path, ".env.example");
189 }
190
191 #[test]
192 fn test_preflight_error_display() {
193 let err = PreflightError::NotGitRepo;
194 assert_eq!(err.to_string(), "Not a git repository");
195 }
196
197 #[test]
198 fn test_diff_too_large_error_display() {
199 let err = PreflightError::DiffTooLarge { size: 23450 };
200 assert_eq!(err.to_string(), "Diff too large: 23450 chars");
201 }
202}