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)]
27#[allow(dead_code)]
28pub struct PreflightSuccess {
29 pub diff_content: String,
30}
31
32fn is_git_repo() -> bool {
33 std::process::Command::new("git")
34 .args(["rev-parse", "--is-inside-work-tree"])
35 .output()
36 .map(|output| {
37 let stdout = String::from_utf8_lossy(&output.stdout);
38 stdout.trim() == "true"
39 })
40 .unwrap_or(false)
41}
42
43fn get_staged_files() -> Result<Vec<String>, std::io::Error> {
44 let output = std::process::Command::new("git")
45 .args(["diff", "--cached", "--name-only"])
46 .output()?;
47 Ok(String::from_utf8_lossy(&output.stdout)
48 .lines()
49 .map(String::from)
50 .collect())
51}
52
53fn get_unstaged_files() -> Result<Vec<UnstagedFile>, std::io::Error> {
54 let output = std::process::Command::new("git")
55 .args(["status", "-s"])
56 .output()?;
57 Ok(String::from_utf8_lossy(&output.stdout)
58 .lines()
59 .filter_map(|line| {
60 let mut parts = line.splitn(2, ' ');
61 let status = parts.next()?;
62 let path = parts.next().map(|p| p.trim()).unwrap_or("");
63 if status.is_empty() || path.is_empty() {
64 return None; }
66 Some(UnstagedFile {
67 status: status.to_string(),
68 path: path.to_string(),
69 })
70 })
71 .collect())
72}
73
74fn get_diff_content() -> Result<String, std::io::Error> {
75 let output = std::process::Command::new("git")
76 .args(["diff", "--cached"])
77 .output()?;
78 Ok(String::from_utf8_lossy(&output.stdout).to_string())
79}
80
81fn is_working_tree_clean() -> Result<bool, std::io::Error> {
82 let output = std::process::Command::new("git")
83 .args(["status", "--porcelain"])
84 .output()?;
85 let clean = String::from_utf8_lossy(&output.stdout)
86 .lines()
87 .all(|line| line.trim().is_empty());
88 Ok(clean)
89}
90
91pub fn run() -> Result<PreflightSuccess, PreflightError> {
96 if !is_git_repo() {
97 return Err(PreflightError::NotGitRepo);
98 }
99
100 if is_working_tree_clean().unwrap_or(false) {
101 return Err(PreflightError::WorkingTreeClean);
102 }
103
104 let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
105 command: "git diff --cached --name-only".into(),
106 source: e,
107 })?;
108
109 if staged.is_empty() {
110 let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
111 command: "git status -s".into(),
112 source: e,
113 })?;
114 return Err(PreflightError::NoStagedFiles { unstaged });
115 }
116
117 let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
118 command: "git diff --cached".into(),
119 source: e,
120 })?;
121
122 if diff_content.len() > SOFT_DIFF_LIMIT {
123 return Err(PreflightError::DiffTooLarge {
124 size: diff_content.len(),
125 });
126 }
127
128 Ok(PreflightSuccess { diff_content })
129}
130
131pub fn run_with_diff_bypass() -> Result<PreflightSuccess, PreflightError> {
134 if !is_git_repo() {
135 return Err(PreflightError::NotGitRepo);
136 }
137
138 if is_working_tree_clean().unwrap_or(false) {
139 return Err(PreflightError::WorkingTreeClean);
140 }
141
142 let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
143 command: "git diff --cached --name-only".into(),
144 source: e,
145 })?;
146 if staged.is_empty() {
147 let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
148 command: "git status -s".into(),
149 source: e,
150 })?;
151 return Err(PreflightError::NoStagedFiles { unstaged });
152 }
153 let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
154 command: "git diff --cached".into(),
155 source: e,
156 })?;
157 Ok(PreflightSuccess { diff_content })
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn test_unstaged_file_parse_status_M() {
166 let file = UnstagedFile {
167 status: "M".to_string(),
168 path: "src/main.rs".to_string(),
169 };
170 assert_eq!(file.status, "M");
171 assert_eq!(file.path, "src/main.rs");
172 }
173
174 #[test]
175 fn test_unstaged_file_parse_status_UU() {
176 let file = UnstagedFile {
177 status: "??".to_string(),
178 path: ".env.example".to_string(),
179 };
180 assert_eq!(file.status, "??");
181 assert_eq!(file.path, ".env.example");
182 }
183
184 #[test]
185 fn test_preflight_error_display() {
186 let err = PreflightError::NotGitRepo;
187 assert_eq!(err.to_string(), "Not a git repository");
188 }
189
190 #[test]
191 fn test_diff_too_large_error_display() {
192 let err = PreflightError::DiffTooLarge { size: 23450 };
193 assert_eq!(err.to_string(), "Diff too large: 23450 chars");
194 }
195}