git_x/
bisect.rs

1use crate::cli::BisectAction;
2use crate::core::git::GitOperations;
3use crate::core::validation::Validate;
4use crate::{GitXError, Result};
5use console::style;
6use std::process::Command;
7
8pub fn run(action: BisectAction) -> Result<String> {
9    match action {
10        BisectAction::Start { good, bad } => start_bisect(good, bad),
11        BisectAction::Good => mark_good(),
12        BisectAction::Bad => mark_bad(),
13        BisectAction::Skip => skip_commit(),
14        BisectAction::Reset => reset_bisect(),
15        BisectAction::Status => show_status(),
16    }
17}
18
19fn start_bisect(good: String, bad: String) -> Result<String> {
20    // Check if already in bisect mode
21    if is_bisecting()? {
22        return Err(GitXError::GitCommand(
23            "Already in bisect mode. Use 'git x bisect reset' to exit first.".to_string(),
24        ));
25    }
26
27    // Validate that the commits exist
28    validate_commit_exists(&good)?;
29    validate_commit_exists(&bad)?;
30
31    let mut output = Vec::new();
32    output.push(format!(
33        "{} Starting bisect between {} (good) and {} (bad)",
34        style("🔍").bold(),
35        style(&good).green().bold(),
36        style(&bad).red().bold()
37    ));
38
39    // Start git bisect
40    let git_output = Command::new("git")
41        .args(["bisect", "start", &bad, &good])
42        .output()?;
43
44    if !git_output.status.success() {
45        return Err(GitXError::GitCommand(format!(
46            "Failed to start bisect: {}",
47            String::from_utf8_lossy(&git_output.stderr).trim()
48        )));
49    }
50
51    // Get current commit info
52    let current_commit = get_current_commit_info()?;
53    output.push(format!(
54        "{} Checked out commit: {}",
55        style("📍").bold(),
56        style(&current_commit).cyan()
57    ));
58
59    let remaining = get_remaining_steps()?;
60    output.push(format!(
61        "{} Approximately {} steps remaining",
62        style("âŗ").bold(),
63        style(remaining).yellow().bold()
64    ));
65
66    output.push(format!(
67        "\n{} Test this commit and run:",
68        style("💡").bold()
69    ));
70    output.push(format!(
71        "  {} if commit is good",
72        style("git x bisect good").green()
73    ));
74    output.push(format!(
75        "  {} if commit is bad",
76        style("git x bisect bad").red()
77    ));
78    output.push(format!(
79        "  {} if commit is untestable",
80        style("git x bisect skip").yellow()
81    ));
82
83    Ok(output.join("\n"))
84}
85
86fn mark_good() -> Result<String> {
87    ensure_bisecting()?;
88
89    let current_commit = get_current_commit_info()?;
90    let mut output = Vec::new();
91    output.push(format!(
92        "{} Marked {} as good",
93        style("✅").bold(),
94        style(&current_commit).green()
95    ));
96
97    let git_output = Command::new("git").args(["bisect", "good"]).output()?;
98
99    if !git_output.status.success() {
100        return Err(GitXError::GitCommand(format!(
101            "Failed to mark commit as good: {}",
102            String::from_utf8_lossy(&git_output.stderr).trim()
103        )));
104    }
105
106    let stdout = String::from_utf8_lossy(&git_output.stdout);
107    if stdout.contains("is the first bad commit") {
108        output.push(format!(
109            "\n{} Found the first bad commit!",
110            style("đŸŽ¯").bold()
111        ));
112        output.push(parse_bisect_result(&stdout));
113        output.push(format!(
114            "\n{} Run {} to return to your original branch",
115            style("💡").bold(),
116            style("git x bisect reset").cyan()
117        ));
118    } else {
119        let new_commit = get_current_commit_info()?;
120        output.push(format!(
121            "{} Checked out commit: {}",
122            style("📍").bold(),
123            style(&new_commit).cyan()
124        ));
125
126        let remaining = get_remaining_steps()?;
127        output.push(format!(
128            "{} Approximately {} steps remaining",
129            style("âŗ").bold(),
130            style(remaining).yellow().bold()
131        ));
132    }
133
134    Ok(output.join("\n"))
135}
136
137fn mark_bad() -> Result<String> {
138    ensure_bisecting()?;
139
140    let current_commit = get_current_commit_info()?;
141    let mut output = Vec::new();
142    output.push(format!(
143        "{} Marked {} as bad",
144        style("❌").bold(),
145        style(&current_commit).red()
146    ));
147
148    let git_output = Command::new("git").args(["bisect", "bad"]).output()?;
149
150    if !git_output.status.success() {
151        return Err(GitXError::GitCommand(format!(
152            "Failed to mark commit as bad: {}",
153            String::from_utf8_lossy(&git_output.stderr).trim()
154        )));
155    }
156
157    let stdout = String::from_utf8_lossy(&git_output.stdout);
158    if stdout.contains("is the first bad commit") {
159        output.push(format!(
160            "\n{} Found the first bad commit!",
161            style("đŸŽ¯").bold()
162        ));
163        output.push(parse_bisect_result(&stdout));
164        output.push(format!(
165            "\n{} Run {} to return to your original branch",
166            style("💡").bold(),
167            style("git x bisect reset").cyan()
168        ));
169    } else {
170        let new_commit = get_current_commit_info()?;
171        output.push(format!(
172            "{} Checked out commit: {}",
173            style("📍").bold(),
174            style(&new_commit).cyan()
175        ));
176
177        let remaining = get_remaining_steps()?;
178        output.push(format!(
179            "{} Approximately {} steps remaining",
180            style("âŗ").bold(),
181            style(remaining).yellow().bold()
182        ));
183    }
184
185    Ok(output.join("\n"))
186}
187
188fn skip_commit() -> Result<String> {
189    ensure_bisecting()?;
190
191    let current_commit = get_current_commit_info()?;
192    let mut output = Vec::new();
193    output.push(format!(
194        "{} Skipped {} (untestable)",
195        style("â­ī¸").bold(),
196        style(&current_commit).yellow()
197    ));
198
199    let git_output = Command::new("git").args(["bisect", "skip"]).output()?;
200
201    if !git_output.status.success() {
202        return Err(GitXError::GitCommand(format!(
203            "Failed to skip commit: {}",
204            String::from_utf8_lossy(&git_output.stderr).trim()
205        )));
206    }
207
208    let new_commit = get_current_commit_info()?;
209    output.push(format!(
210        "{} Checked out commit: {}",
211        style("📍").bold(),
212        style(&new_commit).cyan()
213    ));
214
215    let remaining = get_remaining_steps()?;
216    output.push(format!(
217        "{} Approximately {} steps remaining",
218        style("âŗ").bold(),
219        style(remaining).yellow().bold()
220    ));
221
222    Ok(output.join("\n"))
223}
224
225fn reset_bisect() -> Result<String> {
226    if !is_bisecting()? {
227        return Ok(format!(
228            "{} Not currently in bisect mode",
229            style("â„šī¸").bold()
230        ));
231    }
232
233    let git_output = Command::new("git").args(["bisect", "reset"]).output()?;
234
235    if !git_output.status.success() {
236        return Err(GitXError::GitCommand(format!(
237            "Failed to reset bisect: {}",
238            String::from_utf8_lossy(&git_output.stderr).trim()
239        )));
240    }
241
242    Ok(format!(
243        "{} Bisect session ended, returned to original branch",
244        style("🏁").bold()
245    ))
246}
247
248fn show_status() -> Result<String> {
249    if !is_bisecting()? {
250        return Ok(format!(
251            "{} Not currently in bisect mode",
252            style("â„šī¸").bold()
253        ));
254    }
255
256    let mut output = Vec::new();
257    output.push(format!("{} Bisect Status", style("📊").bold()));
258
259    // Get current commit
260    let current_commit = get_current_commit_info()?;
261    output.push(format!(
262        "{} Current commit: {}",
263        style("📍").bold(),
264        style(&current_commit).cyan()
265    ));
266
267    // Get remaining steps
268    let remaining = get_remaining_steps()?;
269    output.push(format!(
270        "{} Approximately {} steps remaining",
271        style("âŗ").bold(),
272        style(remaining).yellow().bold()
273    ));
274
275    // Get bisect log
276    if let Ok(log) = get_bisect_log() {
277        output.push(format!("\n{} Bisect log:", style("📝").bold()));
278        for entry in log.lines().take(5) {
279            if !entry.trim().is_empty() {
280                output.push(format!("  {}", style(entry.trim()).dim()));
281            }
282        }
283    }
284
285    output.push(format!("\n{} Available commands:", style("💡").bold()));
286    output.push(format!(
287        "  {} - Mark current commit as good",
288        style("git x bisect good").green()
289    ));
290    output.push(format!(
291        "  {} - Mark current commit as bad",
292        style("git x bisect bad").red()
293    ));
294    output.push(format!(
295        "  {} - Skip current commit",
296        style("git x bisect skip").yellow()
297    ));
298    output.push(format!(
299        "  {} - End bisect session",
300        style("git x bisect reset").cyan()
301    ));
302
303    Ok(output.join("\n"))
304}
305
306fn is_bisecting() -> Result<bool> {
307    // Check if .git/BISECT_START file exists, which indicates we're in bisect mode
308    let output = Command::new("git")
309        .args(["rev-parse", "--git-dir"])
310        .output()?;
311
312    if !output.status.success() {
313        return Ok(false);
314    }
315
316    let git_dir_output = String::from_utf8_lossy(&output.stdout);
317    let git_dir = git_dir_output.trim();
318    let bisect_start_path = format!("{git_dir}/BISECT_START");
319
320    Ok(std::path::Path::new(&bisect_start_path).exists())
321}
322
323fn ensure_bisecting() -> Result<()> {
324    if !is_bisecting()? {
325        return Err(GitXError::GitCommand(
326            "Not currently in bisect mode. Use 'git x bisect start <good> <bad>' first."
327                .to_string(),
328        ));
329    }
330    Ok(())
331}
332
333fn validate_commit_exists(commit: &str) -> Result<()> {
334    Validate::commit_exists(commit)
335}
336
337fn get_current_commit_info() -> Result<String> {
338    GitOperations::run(&["log", "-1", "--pretty=format:%h %s"])
339}
340
341fn get_remaining_steps() -> Result<String> {
342    match GitOperations::run(&["bisect", "view", "--pretty=oneline"]) {
343        Ok(output) => {
344            let count = output.lines().count();
345            let steps = (count as f64).log2().ceil() as usize;
346            Ok(steps.to_string())
347        }
348        Err(_) => Ok("unknown".to_string()),
349    }
350}
351
352fn get_bisect_log() -> Result<String> {
353    GitOperations::run(&["bisect", "log"])
354}
355
356pub fn parse_bisect_result(output: &str) -> String {
357    for line in output.lines() {
358        if line.contains("is the first bad commit") {
359            if let Some(commit_hash) = line.split_whitespace().next() {
360                return format!(
361                    "{} First bad commit: {}",
362                    style("đŸŽ¯").bold(),
363                    style(commit_hash).red().bold()
364                );
365            }
366        }
367    }
368    "Bisect completed".to_string()
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::GitXError;
375
376    #[test]
377    fn test_parse_bisect_result() {
378        let sample_output =
379            "abc123def456 is the first bad commit\ncommit abc123def456\nAuthor: Test User";
380        let result = parse_bisect_result(sample_output);
381        assert!(result.contains("abc123def456"));
382        assert!(result.contains("First bad commit"));
383    }
384
385    #[test]
386    fn test_parse_bisect_result_no_match() {
387        let sample_output = "Some other git output\nNo bad commit found";
388        let result = parse_bisect_result(sample_output);
389        assert_eq!(result, "Bisect completed");
390    }
391
392    #[test]
393    fn test_get_current_commit_info_error_handling() {
394        // Test that the function signature works correctly
395        // In a real test environment, we can't actually test git commands,
396        // but we can test the error handling logic
397        let test_result: Result<String> = Err(GitXError::GitCommand("test".to_string()));
398        assert!(test_result.is_err());
399    }
400
401    #[test]
402    fn test_validate_commit_exists_logic() {
403        // Test the function exists and has correct signature
404        // We can't test actual git validation without a repo
405        let _validate_fn = validate_commit_exists;
406        // Function exists and compiles
407    }
408
409    #[test]
410    fn test_is_bisecting_logic() {
411        // Test the function exists and has correct signature
412        let _bisect_fn = is_bisecting;
413        // Function exists and compiles
414    }
415
416    #[test]
417    fn test_ensure_bisecting_logic() {
418        // Test that ensure_bisecting returns appropriate error
419        // In non-bisect mode, this should return an error
420        let result = ensure_bisecting();
421        // The function should either succeed (if we're in a repo with bisect)
422        // or fail with a GitXError (if we're not in bisect mode)
423        match result {
424            Ok(_) => {}                         // Might succeed if in actual bisect
425            Err(GitXError::GitCommand(_)) => {} // Expected
426            Err(GitXError::Io(_)) => {}         // Also possible
427            Err(_) => panic!("Unexpected error type"),
428        }
429    }
430
431    #[test]
432    fn test_get_remaining_steps_fallback() {
433        // Test that get_remaining_steps handles errors gracefully
434        // When git commands fail, it should return "unknown"
435        let _steps_fn = get_remaining_steps;
436        // Function exists and compiles
437    }
438
439    #[test]
440    fn test_get_bisect_log_error_handling() {
441        // Test error handling for bisect log
442        let _log_fn = get_bisect_log;
443        // Function exists and compiles
444    }
445
446    #[test]
447    fn test_bisect_workflow_functions_exist() {
448        // Verify all main workflow functions exist and compile
449        let _start_fn = start_bisect;
450        let _good_fn = mark_good;
451        let _bad_fn = mark_bad;
452        let _skip_fn = skip_commit;
453        let _reset_fn = reset_bisect;
454        let _status_fn = show_status;
455        // All functions exist and compile
456    }
457}