//! Inspection (spec §27): blame from atom keys, and bisect over //! dependency-closed prefixes of the candidates between a good and a bad //! state. use super::*; /// One line of `oxy blame`. #[derive(Clone, Debug)] pub struct BlameLine { pub line: usize, pub hash: ChangeHash, pub index: u32, pub change_id: ChangeId, pub author: String, pub when_ms: u64, pub text: Vec, } /// Where a bisect stands. #[derive(Clone, Debug, PartialEq, Eq)] pub enum BisectStep { /// More `good`/`bad` marks are needed before the search can start. NeedMarks { good: usize, bad: usize }, /// A state is checked out for testing. Testing { heads: Vec, remaining: usize, steps_left: u32, }, /// The first change whose inclusion makes the state bad. Found { culprit: ChangeHash }, /// Every remaining candidate was skipped. Inconclusive { candidates: Vec }, } #[derive(Clone, Debug, Default)] struct BisectState { start: Vec, good: Vec, bad: Vec, candidates: Vec, lo: usize, hi: usize, /// The prefix length currently checked out for testing. testing: Option, skipped: Vec, } fn hashes_line(v: &[ChangeHash]) -> String { v.iter().map(|h| h.to_hex()).collect::>().join(" ") } fn hashes_from(s: &str) -> Vec { s.split_whitespace() .filter_map(ObjectRef::from_hex) .collect() } impl Repo { /// The introducing change, author, and date of every line of `path` in /// the working copy's base state (spec §27). pub fn blame(&self, path: &str) -> Result> { let snap = self.snapshot("oxy blame")?; self.blame_state( path, &snap.base, &snap.base_snapshot.tree, "the working copy's base state", ) } /// Blame `path` in the state with these heads (hosted repositories). pub fn blame_in(&self, path: &str, heads: &[ChangeHash]) -> Result> { let state = self.load_state(heads)?; self.blame_of(&state, &state.tree().view(), path) } /// Blame `path` in a loaded state whose tree view the caller holds; no /// file other than `path` is rendered. pub fn blame_of( &self, state: &crate::state::State, tree: &crate::tree::TreeView, path: &str, ) -> Result> { self.blame_state(path, state, tree, "that state") } fn blame_state( &self, path: &str, state: &crate::state::State, tree: &crate::tree::TreeView, what: &str, ) -> Result> { let Some(f) = tree.entries.get(path) else { return fail(format!("{path} is not in {what}")); }; if f.kind != crate::objects::NodeKind::File || f.mode.opaque { return fail(format!("{path} is not a text file")); } let mut out = Vec::new(); for (i, ((hash, index), text)) in state.live_lines(&f.node).into_iter().enumerate() { let meta = state.get(&hash); let author = match meta { Some(m) if !m.author.is_zero() => self .store .get_identity(&m.author) .map(|id| id.name) .unwrap_or_else(|_| m.author.short(12)), Some(m) => m .message .lines() .rev() .find_map(|l| l.strip_prefix("Git-Author: ")) .map(|a| a.split('<').next().unwrap_or(a).trim().to_string()) .unwrap_or_else(|| "unknown".into()), None => "unknown".into(), }; out.push(BlameLine { line: i + 1, hash, index, change_id: meta.map(|m| m.change_id).unwrap_or(ChangeId([0; 16])), author, when_ms: meta.map(|m| m.hlc.wall_ms).unwrap_or(0), text, }); } Ok(out) } // ------------------------------------------------------------ bisect fn bisect_dir(&self) -> PathBuf { self.oxy.join("bisect") } fn bisect_load(&self) -> Result> { let dir = self.bisect_dir(); if !dir.join("start").exists() { return Ok(None); } let read = |name: &str| fs::read_to_string(dir.join(name)).unwrap_or_default(); let nums: Vec = read("range") .split_whitespace() .filter_map(|n| n.parse().ok()) .collect(); Ok(Some(BisectState { start: hashes_from(&read("start")), good: hashes_from(&read("good")), bad: hashes_from(&read("bad")), candidates: hashes_from(&read("candidates")), lo: nums.first().copied().unwrap_or(0), hi: nums.get(1).copied().unwrap_or(0), testing: nums.get(2).copied().filter(|_| nums.len() > 2), skipped: read("skipped") .split_whitespace() .filter_map(|n| n.parse().ok()) .collect(), })) } fn bisect_save(&self, st: &BisectState) -> Result<()> { let dir = self.bisect_dir(); fs::create_dir_all(&dir)?; write_atomic(&dir.join("start"), hashes_line(&st.start).as_bytes())?; write_atomic(&dir.join("good"), hashes_line(&st.good).as_bytes())?; write_atomic(&dir.join("bad"), hashes_line(&st.bad).as_bytes())?; write_atomic( &dir.join("candidates"), hashes_line(&st.candidates).as_bytes(), )?; let mut range = format!("{} {}", st.lo, st.hi); if let Some(t) = st.testing { range.push_str(&format!(" {t}")); } write_atomic(&dir.join("range"), range.as_bytes())?; write_atomic( &dir.join("skipped"), st.skipped .iter() .map(|n| n.to_string()) .collect::>() .join(" ") .as_bytes(), )?; Ok(()) } /// Begin a bisect: remembers the working copy's base to restore later. pub fn bisect_start(&self) -> Result<()> { if self.bisect_load()?.is_some() { return fail("a bisect is in progress; finish it or run `oxy bisect reset`"); } let (_, wc) = self.working_copy()?; let start = self.load_state(&wc.deps)?.maximal(&wc.deps); self.bisect_save(&BisectState { start, ..Default::default() }) } pub fn bisect_reset(&self) -> Result<()> { let Some(st) = self.bisect_load()? else { return fail("no bisect in progress"); }; self.checkout(&st.start, false, "oxy bisect reset")?; fs::remove_dir_all(self.bisect_dir())?; Ok(()) } pub fn bisect_status(&self) -> Result> { let Some(st) = self.bisect_load()? else { return Ok(None); }; Ok(Some(self.bisect_step(&st))) } fn bisect_step(&self, st: &BisectState) -> BisectStep { if st.candidates.is_empty() { return BisectStep::NeedMarks { good: st.good.len(), bad: st.bad.len(), }; } if st.hi <= st.lo + 1 { return BisectStep::Found { culprit: st.candidates[st.lo], }; } match st.testing { Some(_) => { let remaining = st.hi - st.lo; BisectStep::Testing { heads: self.bisect_heads(st, st.testing.unwrap()), remaining, steps_left: (remaining as f64).log2().ceil() as u32, } } None => BisectStep::Inconclusive { candidates: st.candidates[st.lo..st.hi].to_vec(), }, } } /// `good ∪ candidates[..k]`, as heads. fn bisect_heads(&self, st: &BisectState, k: usize) -> Vec { let mut all = st.good.clone(); all.extend(st.candidates[..k].iter().copied()); self.load_state(&all) .map(|s| s.maximal(&all)) .unwrap_or(all) } /// Check out the midpoint of the open range, skipping indices marked /// untestable, and record it as the state under test. fn bisect_advance(&self, st: &mut BisectState) -> Result { if st.hi <= st.lo + 1 { st.testing = None; self.bisect_save(st)?; return Ok(BisectStep::Found { culprit: st.candidates[st.lo], }); } let mid = st.lo + (st.hi - st.lo) / 2; // Try mid, then the nearest untried indices on either side. let mut order = vec![mid]; for d in 1..(st.hi - st.lo) { if mid + d < st.hi { order.push(mid + d); } if mid > st.lo + d { order.push(mid - d); } } let Some(k) = order.into_iter().find(|k| !st.skipped.contains(k)) else { st.testing = None; self.bisect_save(st)?; return Ok(BisectStep::Inconclusive { candidates: st.candidates[st.lo..st.hi].to_vec(), }); }; let heads = self.bisect_heads(st, k); self.checkout( &heads, false, &format!("oxy bisect (testing {k} of {})", st.candidates.len()), )?; st.testing = Some(k); self.bisect_save(st)?; let remaining = st.hi - st.lo; Ok(BisectStep::Testing { heads, remaining, steps_left: (remaining as f64).log2().ceil() as u32, }) } /// Mark states good or bad. With `changes`, before the search starts, /// they name known-good or known-bad states; without, the state under /// test gets the verdict. `None` for `good` means skip (untestable). pub fn bisect_mark(&self, good: Option, changes: &[ChangeHash]) -> Result { let Some(mut st) = self.bisect_load()? else { return fail("no bisect in progress; run `oxy bisect start`"); }; if !changes.is_empty() { if !st.candidates.is_empty() { return fail("the search has started; mark the state under test with plain `oxy bisect good|bad`"); } match good { Some(true) => st.good.extend(changes.iter().copied()), Some(false) => st.bad.extend(changes.iter().copied()), None => return fail("skip applies to the state under test"), } if st.good.is_empty() || st.bad.is_empty() { self.bisect_save(&st)?; return Ok(BisectStep::NeedMarks { good: st.good.len(), bad: st.bad.len(), }); } let good_heads = self.load_state(&st.good)?.maximal(&st.good); let bad_heads = self.load_state(&st.bad)?.maximal(&st.bad); st.good = good_heads.clone(); st.bad = bad_heads.clone(); st.candidates = self.negotiate(&good_heads, &bad_heads)?; if st.candidates.is_empty() { return fail("the bad state adds nothing to the good state; nothing to bisect"); } st.lo = 0; st.hi = st.candidates.len(); return self.bisect_advance(&mut st); } let Some(k) = st.testing else { return fail("no state is under test; give `oxy bisect good|bad` a change or bookmark to start with"); }; match good { Some(true) => st.lo = k, Some(false) => st.hi = k, None => st.skipped.push(k), } self.bisect_advance(&mut st) } /// Automate with a command run in the working copy: exit 0 is good, 125 /// skips, anything else is bad. pub fn bisect_run(&self, command: &[String]) -> Result { let Some(st) = self.bisect_load()? else { return fail("no bisect in progress; run `oxy bisect start`"); }; if st.testing.is_none() { return Ok(self.bisect_step(&st)); } loop { let status = std::process::Command::new(&command[0]) .args(&command[1..]) .current_dir(&self.root) .status() .map_err(|e| RepoError::Msg(format!("running {}: {e}", command[0])))?; let verdict = match status.code() { Some(0) => Some(true), Some(125) => None, _ => Some(false), }; match self.bisect_mark(verdict, &[])? { BisectStep::Testing { .. } => continue, done => return Ok(done), } } } }