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)]
27pub struct PreflightSuccess {
28    pub diff_content: String,
29}
30
31fn is_git_repo() -> bool {
32    std::process::Command::new("git")
33        .args(["rev-parse", "--is-inside-work-tree"])
34        .output()
35        .map(|output| {
36            let stdout = String::from_utf8_lossy(&output.stdout);
37            stdout.trim() == "true"
38        })
39        .unwrap_or(false)
40}
41
42fn get_staged_files() -> Result<Vec<String>, std::io::Error> {
43    let output = std::process::Command::new("git")
44        .args(["diff", "--cached", "--name-only"])
45        .output()?;
46    Ok(String::from_utf8_lossy(&output.stdout)
47        .lines()
48        .map(String::from)
49        .collect())
50}
51
52fn get_unstaged_files() -> Result<Vec<UnstagedFile>, std::io::Error> {
53    let output = std::process::Command::new("git")
54        .args(["status", "-s"])
55        .output()?;
56    Ok(String::from_utf8_lossy(&output.stdout)
57        .lines()
58        .filter_map(|line| {
59            let bytes = line.as_bytes();
60            if bytes.len() < 4 {
61                return None; // line too short: "M f" is min valid
62            }
63            // git status -s: col1=staged, col2=worktree, space, then path
64            // First char is staged status (or space if no staged change)
65            // Second char is worktree status (or space if no unstaged change)
66            let c1 = bytes[0] as char;
67            let c2 = bytes[1] as char;
68            let path = line[3..].to_string();
69            // Skip if both are spaces (no actual change) or path empty
70            if (c1 == ' ' && c2 == ' ') || path.is_empty() {
71                return None;
72            }
73            Some(UnstagedFile {
74                status: format!("{}{}", c1, c2),
75                path,
76            })
77        })
78        .collect())
79}
80
81fn get_diff_content() -> Result<String, std::io::Error> {
82    let output = std::process::Command::new("git")
83        .args(["diff", "--cached"])
84        .output()?;
85    Ok(String::from_utf8_lossy(&output.stdout).to_string())
86}
87
88fn is_working_tree_clean() -> Result<bool, std::io::Error> {
89    let output = std::process::Command::new("git")
90        .args(["status", "--porcelain"])
91        .output()?;
92    let clean = String::from_utf8_lossy(&output.stdout)
93        .lines()
94        .all(|line| line.trim().is_empty());
95    Ok(clean)
96}
97
98/// Runs pre-flight checks: git repo validity, staged files, diff size.
99///
100/// Returns `Ok(PreflightSuccess)` with diff content if all checks pass.
101/// Returns `Err(PreflightError)` for any failure — does NOT print or exit.
102pub fn run() -> Result<PreflightSuccess, PreflightError> {
103    if !is_git_repo() {
104        return Err(PreflightError::NotGitRepo);
105    }
106
107    if is_working_tree_clean().map_err(|e| PreflightError::GitCommandFailed {
108        command: "git status --porcelain".into(),
109        source: e,
110    })? {
111        return Err(PreflightError::WorkingTreeClean);
112    }
113
114    let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
115        command: "git diff --cached --name-only".into(),
116        source: e,
117    })?;
118
119    if staged.is_empty() {
120        let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
121            command: "git status -s".into(),
122            source: e,
123        })?;
124        return Err(PreflightError::NoStagedFiles { unstaged });
125    }
126
127    let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
128        command: "git diff --cached".into(),
129        source: e,
130    })?;
131
132    if diff_content.len() > SOFT_DIFF_LIMIT {
133        return Err(PreflightError::DiffTooLarge {
134            size: diff_content.len(),
135        });
136    }
137
138    Ok(PreflightSuccess { diff_content })
139}
140
141/// Same as run() but skips the diff size check.
142/// Used when user confirmed they want to proceed despite large diff.
143pub fn run_with_diff_bypass() -> Result<PreflightSuccess, PreflightError> {
144    if !is_git_repo() {
145        return Err(PreflightError::NotGitRepo);
146    }
147
148    if is_working_tree_clean().map_err(|e| PreflightError::GitCommandFailed {
149        command: "git status --porcelain".into(),
150        source: e,
151    })? {
152        return Err(PreflightError::WorkingTreeClean);
153    }
154
155    let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
156        command: "git diff --cached --name-only".into(),
157        source: e,
158    })?;
159    if staged.is_empty() {
160        let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
161            command: "git status -s".into(),
162            source: e,
163        })?;
164        return Err(PreflightError::NoStagedFiles { unstaged });
165    }
166    let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
167        command: "git diff --cached".into(),
168        source: e,
169    })?;
170    Ok(PreflightSuccess { diff_content })
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_unstaged_file_parse_status_m() {
179        let file = UnstagedFile {
180            status: "M".to_string(),
181            path: "src/main.rs".to_string(),
182        };
183        assert_eq!(file.status, "M");
184        assert_eq!(file.path, "src/main.rs");
185    }
186
187    #[test]
188    fn test_unstaged_file_parse_status_uu() {
189        let file = UnstagedFile {
190            status: "??".to_string(),
191            path: ".env.example".to_string(),
192        };
193        assert_eq!(file.status, "??");
194        assert_eq!(file.path, ".env.example");
195    }
196
197    #[test]
198    fn test_preflight_error_display() {
199        let err = PreflightError::NotGitRepo;
200        assert_eq!(err.to_string(), "Not a git repository");
201    }
202
203    #[test]
204    fn test_diff_too_large_error_display() {
205        let err = PreflightError::DiffTooLarge { size: 23450 };
206        assert_eq!(err.to_string(), "Diff too large: 23450 chars");
207    }
208}