OXYGENHUB

Coverage / crates/oxy-core/src/repo/inspect.rs 89.2%

314 of 352 lines run, 42 of 76 functions · run #410 on main · run missed

1
//! Inspection (spec §27): blame from atom keys, and bisect over
2
//! dependency-closed prefixes of the candidates between a good and a bad
3
//! state.
4
5
use super::*;
6
7
/// One line of `oxy blame`.
8
#[derive(Clone, Debug)]
9
pub struct BlameLine {
10
    pub line: usize,
11
    pub hash: ChangeHash,
12
    pub index: u32,
13
    pub change_id: ChangeId,
14
    pub author: String,
15
    pub when_ms: u64,
16
    pub text: Vec<u8>,
17
}
18
19
/// Where a bisect stands.
20
#[derive(Clone, Debug, PartialEq, Eq)]
21
pub enum BisectStep {
22
    /// More `good`/`bad` marks are needed before the search can start.
23
    NeedMarks { good: usize, bad: usize },
24
    /// A state is checked out for testing.
25
    Testing {
26
        heads: Vec<ChangeHash>,
27
        remaining: usize,
28
        steps_left: u32,
29
    },
30
    /// The first change whose inclusion makes the state bad.
31
    Found { culprit: ChangeHash },
32
    /// Every remaining candidate was skipped.
33
    Inconclusive { candidates: Vec<ChangeHash> },
34
}
35
36
#[derive(Clone, Debug, Default)]
37
struct BisectState {
38
    start: Vec<ChangeHash>,
39
    good: Vec<ChangeHash>,
40
    bad: Vec<ChangeHash>,
41
    candidates: Vec<ChangeHash>,
42
    lo: usize,
43
    hi: usize,
44
    /// The prefix length currently checked out for testing.
45
    testing: Option<usize>,
46
    skipped: Vec<usize>,
47
}
48
4976×
fn hashes_line(v: &[ChangeHash]) -> String {
50139×
    v.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(" ")
5176×
}
5284×
fn hashes_from(s: &str) -> Vec<ChangeHash> {
5384×
    s.split_whitespace()
5484×
        .filter_map(ObjectRef::from_hex)
5584×
        .collect()
5684×
}
57
58
impl Repo {
59
    /// The introducing change, author, and date of every line of `path` in
60
    /// the working copy's base state (spec §27).
613×
    pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>> {
623×
        let (_, wc) = self.working_copy()?;
63
        // The working copy's base is what the state index holds.
643×
        let mut state = self.load_state_indexed(&wc.deps)?;
653×
        let tree = self.tree_view(&mut state)?;
663×
        if let Some(e) = tree.entries.get(path) {
672×
            self.load_graphs_of(&mut state, &[e.node])?;
681×
        }
693×
        self.blame_state(path, &state, &tree, "the working copy's base state")
703×
    }
71
72
    /// Blame `path` in the state with these heads (hosted repositories).
732×
    pub fn blame_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<BlameLine>> {
742×
        self.blame_heads(path, heads, "that state")
752×
    }
76
77
    /// Blame `path` in the state with these heads, building only its graph.
782×
    fn blame_heads(&self, path: &str, heads: &[ChangeHash], what: &str) -> Result<Vec<BlameLine>> {
792×
        let mut state = self.load_state_history(heads)?;
802×
        let tree = self.tree_view(&mut state)?;
812×
        if let Some(e) = tree.entries.get(path) {
822×
            self.load_graphs_of(&mut state, &[e.node])?;
830×
        }
842×
        self.blame_state(path, &state, &tree, what)
852×
    }
86
87
    /// Blame `path` in a loaded state whose tree view the caller holds; no
88
    /// file other than `path` is rendered.
892×
    pub fn blame_of(
902×
        &self,
912×
        state: &crate::state::State,
922×
        tree: &crate::tree::TreeView,
932×
        path: &str,
942×
    ) -> Result<Vec<BlameLine>> {
952×
        self.blame_state(path, state, tree, "that state")
962×
    }
97
987×
    fn blame_state(
997×
        &self,
1007×
        path: &str,
1017×
        state: &crate::state::State,
1027×
        tree: &crate::tree::TreeView,
1037×
        what: &str,
1047×
    ) -> Result<Vec<BlameLine>> {
1057×
        let Some(f) = tree.entries.get(path) else {
1062×
            return fail(format!("{path} is not in {what}"));
107
        };
1085×
        if f.kind != crate::objects::NodeKind::File || f.mode.opaque {
1090×
            return fail(format!("{path} is not a text file"));
1105×
        }
1115×
        let _t = crate::timing::phase("blame: lines");
1125×
        let mut out = Vec::new();
113
        // Each author's name read once, not once per line.
1145×
        let mut names: HashMap<crate::hash::KeyId, String> = HashMap::new();
11510×
        for (i, ((hash, index), text)) in state.live_lines(&f.node).into_iter().enumerate() {
11610×
            let meta = state.get(&hash);
11710×
            let author = match meta {
11810×
                Some(m) if !m.author.is_zero() => names
1194×
                    .entry(m.author)
1204×
                    .or_insert_with(|| {
1212×
                        self.store
1222×
                            .get_identity(&m.author)
1232×
                            .map(|id| id.name)
1242×
                            .unwrap_or_else(|_| m.author.short(12))
1252×
                    })
1264×
                    .clone(),
1276×
                Some(m) => m
1286×
                    .message
1296×
                    .lines()
1306×
                    .rev()
1316×
                    .find_map(|l| l.strip_prefix("Git-Author: "))
1326×
                    .map(|a| a.split('<').next().unwrap_or(a).trim().to_string())
1336×
                    .unwrap_or_else(|| "unknown".into()),
1340×
                None => "unknown".into(),
135
            };
13610×
            out.push(BlameLine {
13710×
                line: i + 1,
13810×
                hash,
13910×
                index,
14010×
                change_id: meta.map(|m| m.change_id).unwrap_or(ChangeId([0; 16])),
14110×
                author,
14210×
                when_ms: meta.map(|m| m.hlc.wall_ms).unwrap_or(0),
14310×
                text,
144
            });
145
        }
1465×
        Ok(out)
1477×
    }
148
149
    // ------------------------------------------------------------ bisect
150
15148×
    fn bisect_dir(&self) -> PathBuf {
15248×
        self.oxy.join("bisect")
15348×
    }
154
15526×
    fn bisect_load(&self) -> Result<Option<BisectState>> {
15626×
        let dir = self.bisect_dir();
15726×
        if !dir.join("start").exists() {
1585×
            return Ok(None);
15921×
        }
160126×
        let read = |name: &str| fs::read_to_string(dir.join(name)).unwrap_or_default();
16121×
        let nums: Vec<usize> = read("range")
16221×
            .split_whitespace()
16353×
            .filter_map(|n| n.parse().ok())
16421×
            .collect();
165
        Ok(Some(BisectState {
16621×
            start: hashes_from(&read("start")),
16721×
            good: hashes_from(&read("good")),
16821×
            bad: hashes_from(&read("bad")),
16921×
            candidates: hashes_from(&read("candidates")),
17021×
            lo: nums.first().copied().unwrap_or(0),
17121×
            hi: nums.get(1).copied().unwrap_or(0),
17221×
            testing: nums.get(2).copied().filter(|_| nums.len() > 2),
17321×
            skipped: read("skipped")
17421×
                .split_whitespace()
17521×
                .filter_map(|n| n.parse().ok())
17621×
                .collect(),
177
        }))
17826×
    }
179
18019×
    fn bisect_save(&self, st: &BisectState) -> Result<()> {
18119×
        let dir = self.bisect_dir();
18219×
        fs::create_dir_all(&dir)?;
18319×
        write_atomic(&dir.join("start"), hashes_line(&st.start).as_bytes())?;
18419×
        write_atomic(&dir.join("good"), hashes_line(&st.good).as_bytes())?;
18519×
        write_atomic(&dir.join("bad"), hashes_line(&st.bad).as_bytes())?;
18619×
        write_atomic(
18719×
            &dir.join("candidates"),
18819×
            hashes_line(&st.candidates).as_bytes(),
1890×
        )?;
19019×
        let mut range = format!("{} {}", st.lo, st.hi);
19119×
        if let Some(t) = st.testing {
19210×
            range.push_str(&format!(" {t}"));
19310×
        }
19419×
        write_atomic(&dir.join("range"), range.as_bytes())?;
19519×
        write_atomic(
19619×
            &dir.join("skipped"),
19719×
            st.skipped
19819×
                .iter()
19919×
                .map(|n| n.to_string())
20019×
                .collect::<Vec<_>>()
20119×
                .join(" ")
20219×
                .as_bytes(),
2030×
        )?;
20419×
        Ok(())
20519×
    }
206
207
    /// Begin a bisect: remembers the working copy's base to restore later.
2083×
    pub fn bisect_start(&self) -> Result<()> {
2093×
        if self.bisect_load()?.is_some() {
2100×
            return fail("a bisect is in progress; finish it or run `oxy bisect reset`");
2113×
        }
2123×
        let (_, wc) = self.working_copy()?;
2133×
        let start = self.load_state_history(&wc.deps)?.maximal(&wc.deps);
2143×
        self.bisect_save(&BisectState {
2153×
            start,
2163×
            ..Default::default()
2173×
        })
2183×
    }
219
2203×
    pub fn bisect_reset(&self) -> Result<()> {
2213×
        let Some(st) = self.bisect_load()? else {
2220×
            return fail("no bisect in progress");
223
        };
2243×
        self.checkout(&st.start, false, "oxy bisect reset")?;
2253×
        fs::remove_dir_all(self.bisect_dir())?;
2263×
        Ok(())
2273×
    }
228
2293×
    pub fn bisect_status(&self) -> Result<Option<BisectStep>> {
2303×
        let Some(st) = self.bisect_load()? else {
2312×
            return Ok(None);
232
        };
2331×
        Ok(Some(self.bisect_step(&st)))
2343×
    }
235
2361×
    fn bisect_step(&self, st: &BisectState) -> BisectStep {
2371×
        if st.candidates.is_empty() {
2380×
            return BisectStep::NeedMarks {
2390×
                good: st.good.len(),
2400×
                bad: st.bad.len(),
2410×
            };
2421×
        }
2431×
        if st.hi <= st.lo + 1 {
2441×
            return BisectStep::Found {
2451×
                culprit: st.candidates[st.lo],
2461×
            };
2470×
        }
2480×
        match st.testing {
249
            Some(_) => {
2500×
                let remaining = st.hi - st.lo;
2510×
                BisectStep::Testing {
2520×
                    heads: self.bisect_heads(st, st.testing.unwrap()),
2530×
                    remaining,
2540×
                    steps_left: (remaining as f64).log2().ceil() as u32,
2550×
                }
256
            }
2570×
            None => BisectStep::Inconclusive {
2580×
                candidates: st.candidates[st.lo..st.hi].to_vec(),
2590×
            },
260
        }
2611×
    }
262
263
    /// `good ∪ candidates[..k]`, as heads.
26410×
    fn bisect_heads(&self, st: &BisectState, k: usize) -> Vec<ChangeHash> {
26510×
        let mut all = st.good.clone();
26610×
        all.extend(st.candidates[..k].iter().copied());
26710×
        self.load_state_history(&all)
26810×
            .map(|s| s.maximal(&all))
26910×
            .unwrap_or(all)
27010×
    }
271
272
    /// Check out the midpoint of the open range, skipping indices marked
273
    /// untestable, and record it as the state under test.
27413×
    fn bisect_advance(&self, st: &mut BisectState) -> Result<BisectStep> {
27513×
        if st.hi <= st.lo + 1 {
2762×
            st.testing = None;
2772×
            self.bisect_save(st)?;
2782×
            return Ok(BisectStep::Found {
2792×
                culprit: st.candidates[st.lo],
2802×
            });
28111×
        }
28211×
        let mid = st.lo + (st.hi - st.lo) / 2;
283
        // Try mid, then the nearest untried indices on either side.
28411×
        let mut order = vec![mid];
28535×
        for d in 1..(st.hi - st.lo) {
28635×
            if mid + d < st.hi {
28714×
                order.push(mid + d);
28821×
            }
28935×
            if mid > st.lo + d {
29010×
                order.push(mid - d);
29125×
            }
292
        }
29312×
        let Some(k) = order.into_iter().find(|k| !st.skipped.contains(k)) else {
2941×
            st.testing = None;
2951×
            self.bisect_save(st)?;
2961×
            return Ok(BisectStep::Inconclusive {
2971×
                candidates: st.candidates[st.lo..st.hi].to_vec(),
2981×
            });
299
        };
30010×
        let heads = self.bisect_heads(st, k);
30110×
        self.checkout(
30210×
            &heads,
303
            false,
30410×
            &format!("oxy bisect (testing {k} of {})", st.candidates.len()),
3050×
        )?;
30610×
        st.testing = Some(k);
30710×
        self.bisect_save(st)?;
30810×
        let remaining = st.hi - st.lo;
30910×
        Ok(BisectStep::Testing {
31010×
            heads,
31110×
            remaining,
31210×
            steps_left: (remaining as f64).log2().ceil() as u32,
31310×
        })
31413×
    }
315
316
    /// Mark states good or bad. With `changes`, before the search starts,
317
    /// they name known-good or known-bad states; without, the state under
318
    /// test gets the verdict. `None` for `good` means skip (untestable).
31916×
    pub fn bisect_mark(&self, good: Option<bool>, changes: &[ChangeHash]) -> Result<BisectStep> {
32016×
        let Some(mut st) = self.bisect_load()? else {
3210×
            return fail("no bisect in progress; run `oxy bisect start`");
322
        };
32316×
        if !changes.is_empty() {
3246×
            if !st.candidates.is_empty() {
3250×
                return fail("the search has started; mark the state under test with plain `oxy bisect good|bad`");
3266×
            }
3276×
            match good {
3283×
                Some(true) => st.good.extend(changes.iter().copied()),
3293×
                Some(false) => st.bad.extend(changes.iter().copied()),
3300×
                None => return fail("skip applies to the state under test"),
331
            }
3326×
            if st.good.is_empty() || st.bad.is_empty() {
3333×
                self.bisect_save(&st)?;
3343×
                return Ok(BisectStep::NeedMarks {
3353×
                    good: st.good.len(),
3363×
                    bad: st.bad.len(),
3373×
                });
3383×
            }
3393×
            let good_heads = self.load_state_history(&st.good)?.maximal(&st.good);
3403×
            let bad_heads = self.load_state_history(&st.bad)?.maximal(&st.bad);
3413×
            st.good = good_heads.clone();
3423×
            st.bad = bad_heads.clone();
3433×
            st.candidates = self.negotiate(&good_heads, &bad_heads)?;
3443×
            if st.candidates.is_empty() {
3450×
                return fail("the bad state adds nothing to the good state; nothing to bisect");
3463×
            }
3473×
            st.lo = 0;
3483×
            st.hi = st.candidates.len();
3493×
            return self.bisect_advance(&mut st);
35010×
        }
35110×
        let Some(k) = st.testing else {
3520×
            return fail("no state is under test; give `oxy bisect good|bad` a change or bookmark to start with");
353
        };
35410×
        match good {
3553×
            Some(true) => st.lo = k,
3565×
            Some(false) => st.hi = k,
3572×
            None => st.skipped.push(k),
358
        }
35910×
        self.bisect_advance(&mut st)
36016×
    }
361
362
    /// Automate with a command run in the working copy: exit 0 is good, 125
363
    /// skips, anything else is bad.
3641×
    pub fn bisect_run(&self, command: &[String]) -> Result<BisectStep> {
3651×
        let Some(st) = self.bisect_load()? else {
3660×
            return fail("no bisect in progress; run `oxy bisect start`");
367
        };
3681×
        if st.testing.is_none() {
3690×
            return Ok(self.bisect_step(&st));
3701×
        }
371
        loop {
3723×
            let status = std::process::Command::new(&command[0])
3733×
                .args(&command[1..])
3743×
                .current_dir(&self.root)
3753×
                .status()
3763×
                .map_err(|e| RepoError::Msg(format!("running {}: {e}", command[0])))?;
3773×
            let verdict = match status.code() {
3781×
                Some(0) => Some(true),
3790×
                Some(125) => None,
3802×
                _ => Some(false),
381
            };
3823×
            match self.bisect_mark(verdict, &[])? {
3832×
                BisectStep::Testing { .. } => continue,
3841×
                done => return Ok(done),
385
            }
386
        }
3871×
    }
388
}
389
390
/// What `oxy describe` reports about the working copy (spec §10.1).
391
#[derive(Clone, Debug, PartialEq, Eq)]
392
pub struct Description {
393
    /// The tag of the base state, or the nearest tag below it.
394
    pub tag: Option<String>,
395
    /// Changes in the base state that the tag's state lacks (0: exact).
396
    pub distance: usize,
397
    pub bookmark: String,
398
    /// The base's newest head.
399
    pub change: ChangeId,
400
    pub hash: ChangeHash,
401
    /// Whether the working copy has uncommitted changes.
402
    pub dirty: bool,
403
}
404
405
impl Description {
406
    /// `v1.2`, `v1.2-3-8c5ea12a`, or `develop-8c5ea12a`, and `-dirty`.
4074×
    pub fn line(&self) -> String {
4084×
        let short = self.change.short(8);
4094×
        let base = match (&self.tag, self.distance) {
4102×
            (Some(t), 0) => t.clone(),
4111×
            (Some(t), n) => format!("{t}-{n}-{short}"),
4121×
            (None, _) => format!("{}-{short}", self.bookmark),
413
        };
4144×
        if self.dirty {
4151×
            format!("{base}-dirty")
416
        } else {
4173×
            base
418
        }
4194×
    }
420
}
421
422
impl Repo {
423
    /// Name the working copy for builds (`oxy describe`, like `git
424
    /// describe`): the tag of its base state; else the nearest tag whose
425
    /// state the base contains, with how many changes it adds and its
426
    /// newest head; else the bookmark and that head. `dirty` when the
427
    /// working copy has uncommitted changes.
4284×
    pub fn describe_working_copy(&self) -> Result<Description> {
4294×
        let snap = self.snapshot_status("oxy describe")?;
4304×
        let dirty = !snap.statuses.is_empty();
4314×
        let base = snap.base.maximal(&snap.wc.deps);
4324×
        let Some(&newest) = base
4334×
            .iter()
4346×
            .max_by_key(|h| snap.base.get(h).map(|c| (c.hlc, **h)))
435
        else {
4360×
            return fail("the working copy sits on no change yet");
437
        };
4384×
        let Some(change) = snap.base.get(&newest).map(|m| m.change_id) else {
4390×
            return fail("the working copy's base is not loaded");
440
        };
4414×
        let mut sorted = base.clone();
4424×
        sorted.sort();
4434×
        let mut best: Option<(usize, String)> = None;
4444×
        for t in self.tags()? {
4453×
            if !t.heads.iter().all(|h| snap.base.contains(h)) {
4460×
                continue;
4473×
            }
4483×
            let mut tagged: std::collections::HashSet<ChangeHash> =
4493×
                t.heads.iter().copied().collect();
4503×
            for h in &t.heads {
4513×
                tagged.extend(snap.base.closure(h).iter().copied());
4523×
            }
4533×
            let mut heads = t.heads.clone();
4543×
            heads.sort();
4553×
            let distance = if heads == sorted {
4562×
                0
457
            } else {
4581×
                snap.base.len().saturating_sub(tagged.len()).max(1)
459
            };
4603×
            if best
4613×
                .as_ref()
4623×
                .is_none_or(|(d, n)| distance < *d || (distance == *d && t.name < *n))
4633×
            {
4643×
                best = Some((distance, t.name));
4653×
            }
466
        }
4674×
        let (distance, tag) = match best {
4683×
            Some((d, t)) => (d, Some(t)),
4691×
            None => (0, None),
470
        };
4714×
        Ok(Description {
4724×
            tag,
4734×
            distance,
4744×
            bookmark: self.config.bookmark.clone(),
4754×
            change,
4764×
            hash: newest,
4774×
            dirty,
4784×
        })
4794×
    }
480
}