Skip to main content

git_comma/
preflight.rs

1pub use crate::filter::{filter_staged_files, FilterMode};
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    pub is_static_message: bool,
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 bytes = line.as_bytes();
61            if bytes.len() < 4 {
62                return None; // line too short: "M f" is min valid
63            }
64            // git status -s: col1=staged, col2=worktree, space, then path
65            // First char is staged status (or space if no staged change)
66            // Second char is worktree status (or space if no unstaged change)
67            let c1 = bytes[0] as char;
68            let c2 = bytes[1] as char;
69            let path = line[3..].to_string();
70            // Skip if both are spaces (no actual change) or path empty
71            if (c1 == ' ' && c2 == ' ') || path.is_empty() {
72                return None;
73            }
74            Some(UnstagedFile {
75                status: format!("{}{}", c1, c2),
76                path,
77            })
78        })
79        .collect())
80}
81
82fn get_diff_content() -> Result<String, std::io::Error> {
83    let output = std::process::Command::new("git")
84        .args(["diff", "--cached"])
85        .output()?;
86    Ok(String::from_utf8_lossy(&output.stdout).to_string())
87}
88
89fn is_working_tree_clean() -> Result<bool, std::io::Error> {
90    let output = std::process::Command::new("git")
91        .args(["status", "--porcelain"])
92        .output()?;
93    let clean = String::from_utf8_lossy(&output.stdout)
94        .lines()
95        .all(|line| line.trim().is_empty());
96    Ok(clean)
97}
98
99/// Checks whether the diff content exceeds the given limit.
100///
101/// Returns Ok(()) if within limit, Err(DiffTooLarge) if exceeded.
102pub fn check_diff_size(diff: &str, limit: usize) -> Result<(), PreflightError> {
103    if diff.len() > limit {
104        Err(PreflightError::DiffTooLarge { size: diff.len() })
105    } else {
106        Ok(())
107    }
108}
109
110/// Runs pre-flight checks: git repo validity, staged files, diff size.
111///
112/// Returns `Ok(PreflightSuccess)` with diff content if all checks pass.
113/// Returns `Err(PreflightError)` for any failure — does NOT print or exit.
114pub fn run(limit: usize) -> Result<PreflightSuccess, PreflightError> {
115    run_with_filter(FilterMode::Smart, limit)
116}
117
118/// Same as run() but allows explicit FilterMode (for --no-filter support).
119pub fn run_with_filter(mode: FilterMode, limit: usize) -> Result<PreflightSuccess, PreflightError> {
120    if !is_git_repo() {
121        return Err(PreflightError::NotGitRepo);
122    }
123
124    if is_working_tree_clean().map_err(|e| PreflightError::GitCommandFailed {
125        command: "git status --porcelain".into(),
126        source: e,
127    })? {
128        return Err(PreflightError::WorkingTreeClean);
129    }
130
131    let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
132        command: "git diff --cached --name-only".into(),
133        source: e,
134    })?;
135
136    if staged.is_empty() {
137        let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
138            command: "git status -s".into(),
139            source: e,
140        })?;
141        return Err(PreflightError::NoStagedFiles { unstaged });
142    }
143
144    // Run the filter
145    let filter_result = filter_staged_files(mode).map_err(|e| {
146        PreflightError::GitCommandFailed {
147            command: "git diff --cached --numstat".into(),
148            source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
149        }
150    })?;
151
152    // Check if all staged files were excluded (static message path)
153    if filter_result.all_machine_generated() {
154        return Ok(PreflightSuccess {
155            diff_content: "chore: update dependencies".to_string(),
156            is_static_message: true,
157        });
158    }
159
160    // All files excluded due to HeuristicSize (not machine-generated): fall back to full diff.
161    // This avoids a double git diff --cached call by getting full diff directly.
162    let diff_content = if filter_result.all_excluded {
163        get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
164            command: "git diff --cached".into(),
165            source: e,
166        })?
167    } else if filter_result.excluded.is_empty() {
168        get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
169            command: "git diff --cached".into(),
170            source: e,
171        })?
172    } else {
173        let exclude_args = crate::filter::build_git_exclude_args(&filter_result.excluded);
174        get_filtered_diff_content(&exclude_args).map_err(|e| PreflightError::GitCommandFailed {
175            command: "git diff --cached :(exclude)".into(),
176            source: e,
177        })?
178    };
179
180    // Check diff size limit
181    check_diff_size(&diff_content, limit)?;
182
183    Ok(PreflightSuccess {
184        diff_content,
185        is_static_message: false,
186    })
187}
188
189/// Builds git diff command with exclude pathspec arguments.
190fn get_filtered_diff_content(exclude_args: &[String]) -> Result<String, std::io::Error> {
191    let mut cmd = std::process::Command::new("git");
192    cmd.arg("diff").arg("--cached");
193    for arg in exclude_args {
194        cmd.arg(arg);
195    }
196    let output = cmd.output()?;
197    Ok(String::from_utf8_lossy(&output.stdout).to_string())
198}
199
200/// Same as run() but skips the diff size check.
201/// Used when user confirmed they want to proceed despite large diff.
202pub fn run_with_diff_bypass(limit: usize) -> Result<PreflightSuccess, PreflightError> {
203    // Reuse all checks from run_with_filter(NoFilter) but ignore DiffTooLarge
204    match run_with_filter(FilterMode::NoFilter, limit) {
205        Ok(s) => Ok(s),
206        Err(PreflightError::DiffTooLarge { .. }) => {
207            let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
208                command: "git diff --cached".into(),
209                source: e,
210            })?;
211            Ok(PreflightSuccess { diff_content, is_static_message: false })
212        }
213        Err(e) => Err(e),
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_unstaged_file_parse_status_m() {
223        let file = UnstagedFile {
224            status: "M".to_string(),
225            path: "src/main.rs".to_string(),
226        };
227        assert_eq!(file.status, "M");
228        assert_eq!(file.path, "src/main.rs");
229    }
230
231    #[test]
232    fn test_unstaged_file_parse_status_uu() {
233        let file = UnstagedFile {
234            status: "??".to_string(),
235            path: ".env.example".to_string(),
236        };
237        assert_eq!(file.status, "??");
238        assert_eq!(file.path, ".env.example");
239    }
240
241    #[test]
242    fn test_preflight_error_display() {
243        let err = PreflightError::NotGitRepo;
244        assert_eq!(err.to_string(), "Not a git repository");
245    }
246
247    #[test]
248    fn test_diff_too_large_error_display() {
249        let err = PreflightError::DiffTooLarge { size: 23450 };
250        assert_eq!(err.to_string(), "Diff too large: 23450 chars");
251    }
252}