OXYGENHUB
# afaba5f1306d oxygen / crates / oxy-core / src / repo / inspect.rs
12.8 KiB · Rust · last changed History and blame of a loaded state: log_of, log_for_path_of with a limit, and blame_of find the path in the tree alone and render nothing else; the *_in forms load once instead of twice 1 month ago source blame history raw
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
49
fn hashes_line(v: &[ChangeHash]) -> String {
50
    v.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(" ")
51
}
52
fn hashes_from(s: &str) -> Vec<ChangeHash> {
53
    s.split_whitespace()
54
        .filter_map(ObjectRef::from_hex)
55
        .collect()
56
}
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).
61
    pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>> {
62
        let snap = self.snapshot("oxy blame")?;
63
        self.blame_state(
64
            path,
65
            &snap.base,
66
            &snap.base_snapshot.tree,
67
            "the working copy's base state",
68
        )
69
    }
70
71
    /// Blame `path` in the state with these heads (hosted repositories).
72
    pub fn blame_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<BlameLine>> {
73
        let state = self.load_state(heads)?;
74
        self.blame_of(&state, &state.tree().view(), path)
75
    }
76
77
    /// Blame `path` in a loaded state whose tree view the caller holds; no
78
    /// file other than `path` is rendered.
79
    pub fn blame_of(
80
        &self,
81
        state: &crate::state::State,
82
        tree: &crate::tree::TreeView,
83
        path: &str,
84
    ) -> Result<Vec<BlameLine>> {
85
        self.blame_state(path, state, tree, "that state")
86
    }
87
88
    fn blame_state(
89
        &self,
90
        path: &str,
91
        state: &crate::state::State,
92
        tree: &crate::tree::TreeView,
93
        what: &str,
94
    ) -> Result<Vec<BlameLine>> {
95
        let Some(f) = tree.entries.get(path) else {
96
            return fail(format!("{path} is not in {what}"));
97
        };
98
        if f.kind != crate::objects::NodeKind::File || f.mode.opaque {
99
            return fail(format!("{path} is not a text file"));
100
        }
101
        let mut out = Vec::new();
102
        for (i, ((hash, index), text)) in state.live_lines(&f.node).into_iter().enumerate() {
103
            let meta = state.get(&hash);
104
            let author = match meta {
105
                Some(m) if !m.author.is_zero() => self
106
                    .store
107
                    .get_identity(&m.author)
108
                    .map(|id| id.name)
109
                    .unwrap_or_else(|_| m.author.short(12)),
110
                Some(m) => m
111
                    .message
112
                    .lines()
113
                    .rev()
114
                    .find_map(|l| l.strip_prefix("Git-Author: "))
115
                    .map(|a| a.split('<').next().unwrap_or(a).trim().to_string())
116
                    .unwrap_or_else(|| "unknown".into()),
117
                None => "unknown".into(),
118
            };
119
            out.push(BlameLine {
120
                line: i + 1,
121
                hash,
122
                index,
123
                change_id: meta.map(|m| m.change_id).unwrap_or(ChangeId([0; 16])),
124
                author,
125
                when_ms: meta.map(|m| m.hlc.wall_ms).unwrap_or(0),
126
                text,
127
            });
128
        }
129
        Ok(out)
130
    }
131
132
    // ------------------------------------------------------------ bisect
133
134
    fn bisect_dir(&self) -> PathBuf {
135
        self.oxy.join("bisect")
136
    }
137
138
    fn bisect_load(&self) -> Result<Option<BisectState>> {
139
        let dir = self.bisect_dir();
140
        if !dir.join("start").exists() {
141
            return Ok(None);
142
        }
143
        let read = |name: &str| fs::read_to_string(dir.join(name)).unwrap_or_default();
144
        let nums: Vec<usize> = read("range")
145
            .split_whitespace()
146
            .filter_map(|n| n.parse().ok())
147
            .collect();
148
        Ok(Some(BisectState {
149
            start: hashes_from(&read("start")),
150
            good: hashes_from(&read("good")),
151
            bad: hashes_from(&read("bad")),
152
            candidates: hashes_from(&read("candidates")),
153
            lo: nums.first().copied().unwrap_or(0),
154
            hi: nums.get(1).copied().unwrap_or(0),
155
            testing: nums.get(2).copied().filter(|_| nums.len() > 2),
156
            skipped: read("skipped")
157
                .split_whitespace()
158
                .filter_map(|n| n.parse().ok())
159
                .collect(),
160
        }))
161
    }
162
163
    fn bisect_save(&self, st: &BisectState) -> Result<()> {
164
        let dir = self.bisect_dir();
165
        fs::create_dir_all(&dir)?;
166
        write_atomic(&dir.join("start"), hashes_line(&st.start).as_bytes())?;
167
        write_atomic(&dir.join("good"), hashes_line(&st.good).as_bytes())?;
168
        write_atomic(&dir.join("bad"), hashes_line(&st.bad).as_bytes())?;
169
        write_atomic(
170
            &dir.join("candidates"),
171
            hashes_line(&st.candidates).as_bytes(),
172
        )?;
173
        let mut range = format!("{} {}", st.lo, st.hi);
174
        if let Some(t) = st.testing {
175
            range.push_str(&format!(" {t}"));
176
        }
177
        write_atomic(&dir.join("range"), range.as_bytes())?;
178
        write_atomic(
179
            &dir.join("skipped"),
180
            st.skipped
181
                .iter()
182
                .map(|n| n.to_string())
183
                .collect::<Vec<_>>()
184
                .join(" ")
185
                .as_bytes(),
186
        )?;
187
        Ok(())
188
    }
189
190
    /// Begin a bisect: remembers the working copy's base to restore later.
191
    pub fn bisect_start(&self) -> Result<()> {
192
        if self.bisect_load()?.is_some() {
193
            return fail("a bisect is in progress; finish it or run `oxy bisect reset`");
194
        }
195
        let (_, wc) = self.working_copy()?;
196
        let start = self.load_state(&wc.deps)?.maximal(&wc.deps);
197
        self.bisect_save(&BisectState {
198
            start,
199
            ..Default::default()
200
        })
201
    }
202
203
    pub fn bisect_reset(&self) -> Result<()> {
204
        let Some(st) = self.bisect_load()? else {
205
            return fail("no bisect in progress");
206
        };
207
        self.checkout(&st.start, false, "oxy bisect reset")?;
208
        fs::remove_dir_all(self.bisect_dir())?;
209
        Ok(())
210
    }
211
212
    pub fn bisect_status(&self) -> Result<Option<BisectStep>> {
213
        let Some(st) = self.bisect_load()? else {
214
            return Ok(None);
215
        };
216
        Ok(Some(self.bisect_step(&st)))
217
    }
218
219
    fn bisect_step(&self, st: &BisectState) -> BisectStep {
220
        if st.candidates.is_empty() {
221
            return BisectStep::NeedMarks {
222
                good: st.good.len(),
223
                bad: st.bad.len(),
224
            };
225
        }
226
        if st.hi <= st.lo + 1 {
227
            return BisectStep::Found {
228
                culprit: st.candidates[st.lo],
229
            };
230
        }
231
        match st.testing {
232
            Some(_) => {
233
                let remaining = st.hi - st.lo;
234
                BisectStep::Testing {
235
                    heads: self.bisect_heads(st, st.testing.unwrap()),
236
                    remaining,
237
                    steps_left: (remaining as f64).log2().ceil() as u32,
238
                }
239
            }
240
            None => BisectStep::Inconclusive {
241
                candidates: st.candidates[st.lo..st.hi].to_vec(),
242
            },
243
        }
244
    }
245
246
    /// `good ∪ candidates[..k]`, as heads.
247
    fn bisect_heads(&self, st: &BisectState, k: usize) -> Vec<ChangeHash> {
248
        let mut all = st.good.clone();
249
        all.extend(st.candidates[..k].iter().copied());
250
        self.load_state(&all)
251
            .map(|s| s.maximal(&all))
252
            .unwrap_or(all)
253
    }
254
255
    /// Check out the midpoint of the open range, skipping indices marked
256
    /// untestable, and record it as the state under test.
257
    fn bisect_advance(&self, st: &mut BisectState) -> Result<BisectStep> {
258
        if st.hi <= st.lo + 1 {
259
            st.testing = None;
260
            self.bisect_save(st)?;
261
            return Ok(BisectStep::Found {
262
                culprit: st.candidates[st.lo],
263
            });
264
        }
265
        let mid = st.lo + (st.hi - st.lo) / 2;
266
        // Try mid, then the nearest untried indices on either side.
267
        let mut order = vec![mid];
268
        for d in 1..(st.hi - st.lo) {
269
            if mid + d < st.hi {
270
                order.push(mid + d);
271
            }
272
            if mid > st.lo + d {
273
                order.push(mid - d);
274
            }
275
        }
276
        let Some(k) = order.into_iter().find(|k| !st.skipped.contains(k)) else {
277
            st.testing = None;
278
            self.bisect_save(st)?;
279
            return Ok(BisectStep::Inconclusive {
280
                candidates: st.candidates[st.lo..st.hi].to_vec(),
281
            });
282
        };
283
        let heads = self.bisect_heads(st, k);
284
        self.checkout(
285
            &heads,
286
            false,
287
            &format!("oxy bisect (testing {k} of {})", st.candidates.len()),
288
        )?;
289
        st.testing = Some(k);
290
        self.bisect_save(st)?;
291
        let remaining = st.hi - st.lo;
292
        Ok(BisectStep::Testing {
293
            heads,
294
            remaining,
295
            steps_left: (remaining as f64).log2().ceil() as u32,
296
        })
297
    }
298
299
    /// Mark states good or bad. With `changes`, before the search starts,
300
    /// they name known-good or known-bad states; without, the state under
301
    /// test gets the verdict. `None` for `good` means skip (untestable).
302
    pub fn bisect_mark(&self, good: Option<bool>, changes: &[ChangeHash]) -> Result<BisectStep> {
303
        let Some(mut st) = self.bisect_load()? else {
304
            return fail("no bisect in progress; run `oxy bisect start`");
305
        };
306
        if !changes.is_empty() {
307
            if !st.candidates.is_empty() {
308
                return fail("the search has started; mark the state under test with plain `oxy bisect good|bad`");
309
            }
310
            match good {
311
                Some(true) => st.good.extend(changes.iter().copied()),
312
                Some(false) => st.bad.extend(changes.iter().copied()),
313
                None => return fail("skip applies to the state under test"),
314
            }
315
            if st.good.is_empty() || st.bad.is_empty() {
316
                self.bisect_save(&st)?;
317
                return Ok(BisectStep::NeedMarks {
318
                    good: st.good.len(),
319
                    bad: st.bad.len(),
320
                });
321
            }
322
            let good_heads = self.load_state(&st.good)?.maximal(&st.good);
323
            let bad_heads = self.load_state(&st.bad)?.maximal(&st.bad);
324
            st.good = good_heads.clone();
325
            st.bad = bad_heads.clone();
326
            st.candidates = self.negotiate(&good_heads, &bad_heads)?;
327
            if st.candidates.is_empty() {
328
                return fail("the bad state adds nothing to the good state; nothing to bisect");
329
            }
330
            st.lo = 0;
331
            st.hi = st.candidates.len();
332
            return self.bisect_advance(&mut st);
333
        }
334
        let Some(k) = st.testing else {
335
            return fail("no state is under test; give `oxy bisect good|bad` a change or bookmark to start with");
336
        };
337
        match good {
338
            Some(true) => st.lo = k,
339
            Some(false) => st.hi = k,
340
            None => st.skipped.push(k),
341
        }
342
        self.bisect_advance(&mut st)
343
    }
344
345
    /// Automate with a command run in the working copy: exit 0 is good, 125
346
    /// skips, anything else is bad.
347
    pub fn bisect_run(&self, command: &[String]) -> Result<BisectStep> {
348
        let Some(st) = self.bisect_load()? else {
349
            return fail("no bisect in progress; run `oxy bisect start`");
350
        };
351
        if st.testing.is_none() {
352
            return Ok(self.bisect_step(&st));
353
        }
354
        loop {
355
            let status = std::process::Command::new(&command[0])
356
                .args(&command[1..])
357
                .current_dir(&self.root)
358
                .status()
359
                .map_err(|e| RepoError::Msg(format!("running {}: {e}", command[0])))?;
360
            let verdict = match status.code() {
361
                Some(0) => Some(true),
362
                Some(125) => None,
363
                _ => Some(false),
364
            };
365
            match self.bisect_mark(verdict, &[])? {
366
                BisectStep::Testing { .. } => continue,
367
                done => return Ok(done),
368
            }
369
        }
370
    }
371
}