Skip to main content

git_comma/
preflight.rs

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; // skip malformed line
65            }
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
91/// Runs pre-flight checks: git repo validity, staged files, diff size.
92///
93/// Returns `Ok(PreflightSuccess)` with diff content if all checks pass.
94/// Returns `Err(PreflightError)` for any failure — does NOT print or exit.
95pub 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
131/// Same as run() but skips the diff size check.
132/// Used when user confirmed they want to proceed despite large diff.
133pub 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}