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 | + | } |