Skip to main content

lit/commands/
bisect.rs

1use crate::core::{find_repo_root, read_head, Object, ObjectHash};
2use crate::response::BisectResponse;
3use crate::storage::ObjectStore;
4use serde::{Deserialize, Serialize};
5use std::fs;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8struct BisectState {
9    good: Vec<String>,
10    bad: Vec<String>,
11    current: Option<String>,
12    remaining: Vec<String>,
13    steps: usize,
14}
15
16pub fn execute(
17    command: Option<crate::BisectCommands>,
18) -> Result<BisectResponse, crate::errors::LitError> {
19    let repo_root = find_repo_root()?;
20
21    match command {
22        Some(crate::BisectCommands::Start) => bisect_start(&repo_root),
23        Some(crate::BisectCommands::Good { commit }) => bisect_mark(&repo_root, &commit, true),
24        Some(crate::BisectCommands::Bad { commit }) => bisect_mark(&repo_root, &commit, false),
25        Some(crate::BisectCommands::Reset) => bisect_reset(&repo_root),
26        None => {
27            // Show current bisect status
28            let state = load_bisect_state(&repo_root)?;
29            Ok(BisectResponse {
30                action: "status".to_string(),
31                current: state.current,
32                remaining: state.remaining.len(),
33                steps: state.steps,
34                message: format!("Bisecting: {} commits left to test", state.remaining.len()),
35            })
36        }
37    }
38}
39
40fn bisect_start(repo_root: &std::path::Path) -> Result<BisectResponse, crate::errors::LitError> {
41    let state = BisectState {
42        good: Vec::new(),
43        bad: Vec::new(),
44        current: None,
45        remaining: Vec::new(),
46        steps: 0,
47    };
48
49    save_bisect_state(repo_root, &state)?;
50
51    Ok(BisectResponse {
52        action: "start".to_string(),
53        current: None,
54        remaining: 0,
55        steps: 0,
56        message: "Bisect started. Mark commits as good or bad.".to_string(),
57    })
58}
59
60fn bisect_mark(
61    repo_root: &std::path::Path,
62    commit: &str,
63    is_good: bool,
64) -> Result<BisectResponse, crate::errors::LitError> {
65    let mut state = load_bisect_state(repo_root)?;
66    let store = ObjectStore::new(repo_root);
67
68    let hash = if commit == "HEAD" {
69        read_head(repo_root)?
70    } else {
71        commit.to_string()
72    };
73
74    if is_good {
75        state.good.push(hash.clone());
76    } else {
77        state.bad.push(hash.clone());
78    }
79
80    // If we have both good and bad, compute midpoint
81    if !state.good.is_empty() && !state.bad.is_empty() {
82        let bad_hash = state.bad.last().unwrap().clone();
83        let good_hash = state.good.last().unwrap().clone();
84
85        // Collect commits between bad and good
86        let commits = collect_commits_between(&store, &bad_hash, &good_hash)?;
87        state.remaining = commits;
88
89        if state.remaining.is_empty() {
90            // Found the first bad commit
91            save_bisect_state(repo_root, &state)?;
92            return Ok(BisectResponse {
93                action: if is_good { "good" } else { "bad" }.to_string(),
94                current: Some(bad_hash[..16.min(bad_hash.len())].to_string()),
95                remaining: 0,
96                steps: state.steps,
97                message: format!("First bad commit: {}", &bad_hash[..16.min(bad_hash.len())]),
98            });
99        }
100
101        // Pick midpoint
102        let mid = state.remaining.len() / 2;
103        state.current = Some(state.remaining[mid].clone());
104        state.steps += 1;
105
106        let est_steps = (state.remaining.len() as f64).log2().ceil() as usize;
107
108        save_bisect_state(repo_root, &state)?;
109
110        let current_short = state
111            .current
112            .as_ref()
113            .map(|c| c[..16.min(c.len())].to_string());
114
115        Ok(BisectResponse {
116            action: if is_good { "good" } else { "bad" }.to_string(),
117            current: current_short,
118            remaining: state.remaining.len(),
119            steps: est_steps,
120            message: format!(
121                "Bisecting: {} commits left to test (~{} steps)",
122                state.remaining.len(),
123                est_steps
124            ),
125        })
126    } else {
127        save_bisect_state(repo_root, &state)?;
128        Ok(BisectResponse {
129            action: if is_good { "good" } else { "bad" }.to_string(),
130            current: None,
131            remaining: 0,
132            steps: 0,
133            message: format!(
134                "Marked {} as {}. Need both good and bad commits to start bisecting.",
135                &hash[..16.min(hash.len())],
136                if is_good { "good" } else { "bad" }
137            ),
138        })
139    }
140}
141
142fn bisect_reset(repo_root: &std::path::Path) -> Result<BisectResponse, crate::errors::LitError> {
143    let bisect_path = repo_root.join(".lit").join("bisect.json");
144    if bisect_path.exists() {
145        fs::remove_file(&bisect_path)
146            .map_err(|e| format!("Failed to remove bisect state: {}", e))?;
147    }
148
149    Ok(BisectResponse {
150        action: "reset".to_string(),
151        current: None,
152        remaining: 0,
153        steps: 0,
154        message: "Bisect reset".to_string(),
155    })
156}
157
158fn collect_commits_between(
159    store: &ObjectStore,
160    bad: &str,
161    good: &str,
162) -> Result<Vec<String>, crate::errors::LitError> {
163    let mut commits = Vec::new();
164    let mut current = bad.to_string();
165
166    loop {
167        if current == good {
168            break;
169        }
170
171        let hash = ObjectHash::from_hex(current.clone());
172        let commit = match store.read(&hash) {
173            Ok(Object::Commit(c)) => c,
174            _ => break,
175        };
176
177        commits.push(current);
178
179        match commit.parents.first() {
180            Some(p) => current = p.to_string(),
181            None => break,
182        }
183    }
184
185    Ok(commits)
186}
187
188fn load_bisect_state(repo_root: &std::path::Path) -> Result<BisectState, crate::errors::LitError> {
189    let path = repo_root.join(".lit").join("bisect.json");
190    if !path.exists() {
191        return Err("No bisect in progress. Run `lit bisect start` first.".into());
192    }
193    let data =
194        fs::read_to_string(&path).map_err(|e| format!("Failed to read bisect state: {}", e))?;
195    serde_json::from_str(&data).map_err(|e| format!("Failed to parse bisect state: {}", e).into())
196}
197
198fn save_bisect_state(
199    repo_root: &std::path::Path,
200    state: &BisectState,
201) -> Result<(), crate::errors::LitError> {
202    let path = repo_root.join(".lit").join("bisect.json");
203    let data = serde_json::to_string_pretty(state)
204        .map_err(|e| format!("Failed to serialize bisect state: {}", e))?;
205    fs::write(&path, data).map_err(|e| format!("Failed to write bisect state: {}", e).into())
206}