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("Diff too large: {size} chars")]
15    DiffTooLarge { size: usize },
16}
17
18#[derive(Debug, Clone)]
19pub struct UnstagedFile {
20    pub status: String,
21    pub path: String,
22}
23
24#[derive(Debug)]
25#[allow(dead_code)]
26pub struct PreflightSuccess {
27    pub diff_content: String,
28}
29
30fn is_git_repo() -> bool {
31    std::process::Command::new("git")
32        .args(["rev-parse", "--is-inside-work-tree"])
33        .output()
34        .map(|output| {
35            let stdout = String::from_utf8_lossy(&output.stdout);
36            stdout.trim() == "true"
37        })
38        .unwrap_or(false)
39}
40
41fn get_staged_files() -> Result<Vec<String>, std::io::Error> {
42    let output = std::process::Command::new("git")
43        .args(["diff", "--cached", "--name-only"])
44        .output()?;
45    Ok(String::from_utf8_lossy(&output.stdout)
46        .lines()
47        .map(String::from)
48        .collect())
49}
50
51fn get_unstaged_files() -> Result<Vec<UnstagedFile>, std::io::Error> {
52    let output = std::process::Command::new("git")
53        .args(["status", "-s"])
54        .output()?;
55    Ok(String::from_utf8_lossy(&output.stdout)
56        .lines()
57        .filter_map(|line| {
58            let mut parts = line.splitn(2, ' ');
59            let status = parts.next()?;
60            let path = parts.next().map(|p| p.trim()).unwrap_or("");
61            if status.is_empty() || path.is_empty() {
62                return None; // skip malformed line
63            }
64            Some(UnstagedFile {
65                status: status.to_string(),
66                path: path.to_string(),
67            })
68        })
69        .collect())
70}
71
72fn get_diff_content() -> Result<String, std::io::Error> {
73    let output = std::process::Command::new("git")
74        .args(["diff", "--cached"])
75        .output()?;
76    Ok(String::from_utf8_lossy(&output.stdout).to_string())
77}
78
79/// Runs pre-flight checks: git repo validity, staged files, diff size.
80///
81/// Returns `Ok(PreflightSuccess)` with diff content if all checks pass.
82/// Returns `Err(PreflightError)` for any failure — does NOT print or exit.
83pub fn run() -> Result<PreflightSuccess, PreflightError> {
84    if !is_git_repo() {
85        return Err(PreflightError::NotGitRepo);
86    }
87
88    let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
89        command: "git diff --cached --name-only".into(),
90        source: e,
91    })?;
92
93    if staged.is_empty() {
94        let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
95            command: "git status -s".into(),
96            source: e,
97        })?;
98        return Err(PreflightError::NoStagedFiles { unstaged });
99    }
100
101    let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
102        command: "git diff --cached".into(),
103        source: e,
104    })?;
105
106    if diff_content.len() > SOFT_DIFF_LIMIT {
107        return Err(PreflightError::DiffTooLarge {
108            size: diff_content.len(),
109        });
110    }
111
112    Ok(PreflightSuccess { diff_content })
113}
114
115/// Same as run() but skips the diff size check.
116/// Used when user confirmed they want to proceed despite large diff.
117pub fn run_with_diff_bypass() -> Result<PreflightSuccess, PreflightError> {
118    if !is_git_repo() {
119        return Err(PreflightError::NotGitRepo);
120    }
121    let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
122        command: "git diff --cached --name-only".into(),
123        source: e,
124    })?;
125    if staged.is_empty() {
126        let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
127            command: "git status -s".into(),
128            source: e,
129        })?;
130        return Err(PreflightError::NoStagedFiles { unstaged });
131    }
132    let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
133        command: "git diff --cached".into(),
134        source: e,
135    })?;
136    Ok(PreflightSuccess { diff_content })
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_unstaged_file_parse_status_M() {
145        let file = UnstagedFile {
146            status: "M".to_string(),
147            path: "src/main.rs".to_string(),
148        };
149        assert_eq!(file.status, "M");
150        assert_eq!(file.path, "src/main.rs");
151    }
152
153    #[test]
154    fn test_unstaged_file_parse_status_UU() {
155        let file = UnstagedFile {
156            status: "??".to_string(),
157            path: ".env.example".to_string(),
158        };
159        assert_eq!(file.status, "??");
160        assert_eq!(file.path, ".env.example");
161    }
162
163    #[test]
164    fn test_preflight_error_display() {
165        let err = PreflightError::NotGitRepo;
166        assert_eq!(err.to_string(), "Not a git repository");
167    }
168
169    #[test]
170    fn test_diff_too_large_error_display() {
171        let err = PreflightError::DiffTooLarge { size: 23450 };
172        assert_eq!(err.to_string(), "Diff too large: 23450 chars");
173    }
174}