OXYGENHUB

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 bc489b6c7f69

closes #1
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 14:19 UTC
Signature
✔ signed by Andy McCall
Hash
bc489b6c7f69749ef87bb7d13df4c7e85ebcbd96b64a95ab1d27acfda66c0f7d
Depends on
75167672ebcd 957fee30826e 982864068a59 c6aa5bd9d1c8 c863c7a7a2d1 e02bf0cd0533
Depended on by
c2e1d09047db 79b5a90ffd43 170bb436e1d5 69816d688300 83d61e803ccd e8d38e47e9d7 696bbf29d3eb
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
15 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/oxy-core/src/repo/inspect.rs +16 −6
…
62 unchanged lines
63 63
        self.blame_state(
64 64
            path,
65 65
            &snap.base,
66 -
            &snap.base_snapshot,
66 +
            &snap.base_snapshot.tree,
67 67
            "the working copy's base state",
68 68
        )
69 69
    }
…
1 unchanged lines
71 71
    /// Blame `path` in the state with these heads (hosted repositories).
72 72
    pub fn blame_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<BlameLine>> {
73 73
        let state = self.load_state(heads)?;
74 -
        let snapshot = state.materialize();
75 -
        self.blame_state(path, &state, &snapshot, "that state")
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")
76 86
    }
77 87
78 88
    fn blame_state(
79 89
        &self,
80 90
        path: &str,
81 91
        state: &crate::state::State,
82 -
        snapshot: &crate::state::Snapshot,
92 +
        tree: &crate::tree::TreeView,
83 93
        what: &str,
84 94
    ) -> Result<Vec<BlameLine>> {
85 -
        let Some(f) = snapshot.files.get(path) else {
95 +
        let Some(f) = tree.entries.get(path) else {
86 96
            return fail(format!("{path} is not in {what}"));
87 97
        };
88 -
        if !matches!(f.content, FileContent::Text { .. }) {
98 +
        if f.kind != crate::objects::NodeKind::File || f.mode.opaque {
89 99
            return fail(format!("{path} is not a text file"));
90 100
        }
91 101
        let mut out = Vec::new();
…
270 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +6 −2
…
1247 unchanged lines
1248 1248
    /// The log of the state with these heads, newest first, with no working
1249 1249
    /// copy involved (for hosted repositories and any explicit state).
1250 1250
    pub fn log_state(&self, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> {
1251 -
        let state = self.load_state(heads)?;
1251 +
        Ok(self.log_of(&self.load_state(heads)?))
1252 +
    }
1253 +
1254 +
    /// The log of a state already loaded, newest first.
1255 +
    pub fn log_of(&self, state: &crate::state::State) -> Vec<LogEntry> {
1252 1256
        let head_set: BTreeSet<ChangeHash> = state.heads().into_iter().collect();
1253 1257
        let mut entries: Vec<LogEntry> = state
1254 1258
            .changes()
…
5 unchanged lines
1260 1264
            })
1261 1265
            .collect();
1262 1266
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
1263 -
        Ok(entries)
1267 +
        entries
1264 1268
    }
1265 1269
1266 1270
    /// Resolve a spec to a set of heads: a tag name, the current bookmark's
…
120 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +26 −4
…
1115 unchanged lines
1116 1116
    /// The log of the state with these heads restricted to changes touching
1117 1117
    /// `path` (its node, so renames are followed).
1118 1118
    pub fn log_for_path_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> {
1119 -
        let entries = self.log_state(heads)?;
1120 -
        let snapshot = self.load_state(heads)?.materialize();
1121 -
        let Some(node) = snapshot.files.get(path).map(|f| f.node) else {
1119 +
        self.log_for_path_of(&self.load_state(heads)?, path, None)
1120 +
    }
1121 +
1122 +
    /// The changes of a loaded state touching `path`, newest first, at most
1123 +
    /// `limit` of them. The path is found in the state's tree alone, so no
1124 +
    /// file is rendered, and the walk stops once `limit` are found.
1125 +
    pub fn log_for_path_of(
1126 +
        &self,
1127 +
        state: &State,
1128 +
        path: &str,
1129 +
        limit: Option<usize>,
1130 +
    ) -> Result<Vec<LogEntry>> {
1131 +
        let Some(node) = state.tree().view().entries.get(path).map(|e| e.node) else {
1122 1132
            return fail(format!("{path} is not in that state"));
1123 1133
        };
1124 -
        Ok(self.entries_touching(entries, node))
1134 +
        Ok(self.entries_touching_up_to(self.log_of(state), node, limit))
1125 1135
    }
1126 1136
1127 1137
    fn entries_touching(&self, entries: Vec<LogEntry>, node: NodeId) -> Vec<LogEntry> {
1138 +
        self.entries_touching_up_to(entries, node, None)
1139 +
    }
1140 +
1141 +
    fn entries_touching_up_to(
1142 +
        &self,
1143 +
        entries: Vec<LogEntry>,
1144 +
        node: NodeId,
1145 +
        limit: Option<usize>,
1146 +
    ) -> Vec<LogEntry> {
1128 1147
        let mut out = Vec::new();
1129 1148
        for e in entries {
1149 +
            if limit.is_some_and(|n| out.len() >= n) {
1150 +
                break;
1151 +
            }
1130 1152
            let Ok(c) = self.store.get_change(&e.hash) else {
1131 1153
                continue;
1132 1154
            };
…
912 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +72 −0
…
4524 unchanged lines
4525 4525
        "macOS allows aux.c"
4526 4526
    );
4527 4527
}
4528 +
4529 +
#[test]
4530 +
fn history_and_blame_of_a_loaded_state_match_the_full_walk() {
4531 +
    hermetic();
4532 +
    let dir = tempfile::tempdir().unwrap();
4533 +
    let repo = Repo::init(dir.path()).unwrap();
4534 +
    write(dir.path(), "a.txt", b"one\n");
4535 +
    write(dir.path(), "other.txt", b"x\n");
4536 +
    write(dir.path(), "bin.dat", &[0u8, 1, 2, 0, 255]);
4537 +
    repo.snapshot("t").unwrap();
4538 +
    let c1 = repo.commit("first", &[]).unwrap();
4539 +
    write(dir.path(), "other.txt", b"y\n");
4540 +
    repo.snapshot("t").unwrap();
4541 +
    repo.commit("other only", &[]).unwrap();
4542 +
    write(dir.path(), "a.txt", b"one\ntwo\n");
4543 +
    repo.snapshot("t").unwrap();
4544 +
    let c3 = repo.commit("second line", &[]).unwrap();
4545 +
    write(dir.path(), "other.txt", b"z\n");
4546 +
    repo.snapshot("t").unwrap();
4547 +
    repo.commit("other again", &[]).unwrap();
4548 +
4549 +
    let heads = repo.bookmark_heads().unwrap();
4550 +
    let state = repo.load_state(&heads).unwrap();
4551 +
    let full: Vec<ChangeHash> = repo
4552 +
        .log_for_path_in("a.txt", &heads)
4553 +
        .unwrap()
4554 +
        .iter()
4555 +
        .map(|e| e.hash)
4556 +
        .collect();
4557 +
    assert_eq!(
4558 +
        full,
4559 +
        vec![c3, c1],
4560 +
        "newest first, only changes touching the path"
4561 +
    );
4562 +
    let newest: Vec<ChangeHash> = repo
4563 +
        .log_for_path_of(&state, "a.txt", Some(1))
4564 +
        .unwrap()
4565 +
        .iter()
4566 +
        .map(|e| e.hash)
4567 +
        .collect();
4568 +
    assert_eq!(newest, vec![c3], "stops at the first");
4569 +
    assert_eq!(
4570 +
        repo.log_for_path_of(&state, "a.txt", None).unwrap().len(),
4571 +
        2
4572 +
    );
4573 +
    assert!(repo
4574 +
        .log_for_path_of(&state, "missing.txt", Some(1))
4575 +
        .is_err());
4576 +
    assert_eq!(
4577 +
        repo.log_state(&heads).unwrap().len(),
4578 +
        repo.log_of(&state).len()
4579 +
    );
4580 +
4581 +
    let tree = state.tree().view();
4582 +
    let via_state = repo.blame_of(&state, &tree, "a.txt").unwrap();
4583 +
    let via_heads = repo.blame_in("a.txt", &heads).unwrap();
4584 +
    assert_eq!(
4585 +
        via_state
4586 +
            .iter()
4587 +
            .map(|l| (l.hash, l.text.clone()))
4588 +
            .collect::<Vec<_>>(),
4589 +
        via_heads
4590 +
            .iter()
4591 +
            .map(|l| (l.hash, l.text.clone()))
4592 +
            .collect::<Vec<_>>()
4593 +
    );
4594 +
    assert_eq!(
4595 +
        via_state.iter().map(|l| l.hash).collect::<Vec<_>>(),
4596 +
        vec![c1, c3]
4597 +
    );
4598 +
    assert!(repo.blame_of(&state, &tree, "missing.txt").is_err());
4599 +
}