Skip to main content

git_comma/
preflight.rs

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; // line too short: "M f" is min valid
65            }
66            // git status -s: col1=staged, col2=worktree, space, then path
67            // First char is staged status (or space if no staged change)
68            // Second char is worktree status (or space if no unstaged change)
69            let c1 = bytes[0] as char;
70            let c2 = bytes[1] as char;
71            let path = line[3..].to_string();
72            // Skip if both are spaces (no actual change) or path empty
73            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
101/// Runs pre-flight checks: git repo validity, staged files, diff size.
102///
103/// Returns `Ok(PreflightSuccess)` with diff content if all checks pass.
104/// Returns `Err(PreflightError)` for any failure — does NOT print or exit.
105pub fn run() -> Result<PreflightSuccess, PreflightError> {
106    run_with_filter(FilterMode::Smart)
107}
108
109/// Same as run() but allows explicit FilterMode (for --no-filter support).
110pub 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    // Run the filter
136    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    // Check if all staged files were excluded (static message path)
157    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    // Check diff size limit
165    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
177/// Builds git diff command with exclude pathspec arguments.
178fn 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
188/// Same as run() but skips the diff size check.
189/// Used when user confirmed they want to proceed despite large diff.
190pub fn run_with_diff_bypass() -> Result<PreflightSuccess, PreflightError> {
191    // Reuse all checks from run_with_filter(NoFilter) but ignore DiffTooLarge
192    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}