The history cache already recorded whether a commit had been loaded, but `parents_of()` returned an empty parent list for an interned frontier. This made an external AutoMerge input look like a root and required an AutoMerge- specific workaround that equated edit scope with cache completeness. Honor the existing loaded bit and move read-through ancestry lookup into `HistoryGraph`. Name traversal-only cached edges `known_parents` and limit descendant rewrites to the explicit edit scope. Looking up missing ancestry neither populates the cache nor expands permission to edit it. Extend the exact-scope regression to distinguish unloaded frontiers, loaded roots, and ancestry reads. It failed with `Some([])` before the fix and now passes, together with the external-input replay regressions.
Unified · Split
| @@ -1176,6 +1176,9 @@ views. · 1175 unmodified lines | ||
| 1176 | 1176 | results by immutable commit ID. Idle application state retains detached recipes |
| 1177 | 1177 | and picker data only. Pin-consuming checkouts may reload the current projection |
| 1178 | 1178 | to determine which pins must be retained. |
| 1179 | + The graph distinguishes an unloaded frontier from a loaded root or shallow | |
| 1180 | + boundary. Reading an external input's ancestry does not expand the editable | |
| 1181 | + scope; descendant rewrites remain confined to that scope. | |
| 1179 | 1182 | |
| 1180 | 1183 | ### Reviews |
| 1181 | 1184 | |
| @@ -309,20 +309,6 @@ pub(crate) struct Preparation { · 308 unmodified lines | ||
| 309 | 309 | pub eager: HashSet<ObjectId>, |
| 310 | 310 | } |
| 311 | 311 | |
| 312 | −pub(crate) fn parents( | |
| 313 | − repo: &gix::Repository, | |
| 314 | − graph: &crate::history::HistoryGraph, | |
| 315 | − commit_id: ObjectId, | |
| 316 | −) -> Result<Vec<ObjectId>> { | |
| 317 | − match graph | |
| 318 | − .parents_of(commit_id) | |
| 319 | − .filter(|_| graph.is_in_edit_scope(commit_id)) | |
| 320 | − { | |
| 321 | − Some(parents) => Ok(parents), | |
| 322 | − None => Ok(repo.find_commit(commit_id)?.parent_ids().map(gix::Id::detach).collect()), | |
| 323 | − } | |
| 324 | −} | |
| 325 | − | |
| 326 | 312 | /// Only the checkout's ordinary first-parent path requires conflict materialization. |
| 327 | 313 | /// Inputs beneath an AutoMerge can instead remain pending and be muted. |
| 328 | 314 | pub(crate) fn checkout_path( |
| @@ -900,7 +886,8 @@ pub(crate) fn expand_plan( · 571 unmodified lines | ||
| 900 | 886 | } |
| 901 | 887 | let extra: Vec<_> = affected.into_iter().filter(|id| !original.contains(id)).collect(); |
| 902 | 888 | for &commit_id in &extra { |
| 903 | − let parent_commit_id = parents(repo, graph, commit_id)? | |
| 889 | + let parent_commit_id = graph | |
| 890 | + .parents_or_load(repo, commit_id)? | |
| 904 | 891 | .first() |
| 905 | 892 | .copied() |
| 906 | 893 | .context("a pending input has no parent")?; |
| @@ -1267,7 +1267,7 @@ fn perform_inner( · 1266 unmodified lines | ||
| 1267 | 1267 | pending.retain(|id| Some(*id) != root); |
| 1268 | 1268 | } |
| 1269 | 1269 | for old_id in pending { |
| 1270 | − let old_parents = auto_merge::parents(&repo, graph, old_id)?; | |
| 1270 | + let old_parents = graph.parents_or_load(&repo, old_id)?; | |
| 1271 | 1271 | let mut commit = match replacement.as_ref().filter(|_| Some(old_id) == root) { |
| 1272 | 1272 | Some(commit) => commit.clone(), |
| 1273 | 1273 | None => repo |
| @@ -1546,7 +1546,7 @@ pub(super) fn finish_review_with_progress( · 272 unmodified lines | ||
| 1546 | 1546 | )?; |
| 1547 | 1547 | } |
| 1548 | 1548 | for id in review_ids.iter().chain(&natural_ids) { |
| 1549 | − if auto_merge::parents(&repo, graph, *id)?.len() > 1 | |
| 1549 | + if graph.parents_or_load(&repo, *id)?.len() > 1 | |
| 1550 | 1550 | && !auto_merge::is_auto_merge(&repo.find_commit(*id)?.decode()?.into_owned()?) |
| 1551 | 1551 | { |
| 1552 | 1552 | anyhow::bail!("review finish cannot rewrite merge descendants"); |
| @@ -1607,7 +1607,7 @@ pub(super) fn finish_review_with_progress( · 54 unmodified lines | ||
| 1607 | 1607 | |
| 1608 | 1608 | rewritten.insert(tip, Some(finished_review)); |
| 1609 | 1609 | for old in natural_ids { |
| 1610 | − let old_parents = auto_merge::parents(&repo, graph, old)?; | |
| 1610 | + let old_parents = graph.parents_or_load(&repo, old)?; | |
| 1611 | 1611 | let mut commit = repo.find_commit(old)?.decode()?.into_owned()?; |
| 1612 | 1612 | if auto_merge::is_auto_merge(&commit) { |
| 1613 | 1613 | let eager = conflict.is_none() && auto.eager.contains(&old); |
| @@ -1772,7 +1772,7 @@ pub(crate) fn perform_plan_with_progress( · 158 unmodified lines | ||
| 1772 | 1772 | if automatic && step.squash.contains(&id) { |
| 1773 | 1773 | anyhow::bail!("an AutoMerge cannot be squashed"); |
| 1774 | 1774 | } |
| 1775 | − if auto_merge::parents(&repo, graph, id)?.len() > 1 && !automatic { | |
| 1775 | + if graph.parents_or_load(&repo, id)?.len() > 1 && !automatic { | |
| 1776 | 1776 | anyhow::bail!("merge commits cannot be picked by the rebase editor"); |
| 1777 | 1777 | } |
| 1778 | 1778 | } |
| @@ -1944,7 +1944,7 @@ pub(crate) fn perform_plan_with_progress( · 165 unmodified lines | ||
| 1944 | 1944 | } |
| 1945 | 1945 | let graph_parents = match step.commit { |
| 1946 | 1946 | PlanCommit::Pick(id) | PlanCommit::Copy(id) | PlanCommit::Resolved(id) => { |
| 1947 | − auto_merge::parents(&repo, graph, id)? | |
| 1947 | + graph.parents_or_load(&repo, id)? | |
| 1948 | 1948 | } |
| 1949 | 1949 | PlanCommit::Empty(_) => vec![parent], |
| 1950 | 1950 | }; |
| @@ -2655,7 +2655,7 @@ fn validate( · 704 unmodified lines | ||
| 2655 | 2655 | tree: Tree, |
| 2656 | 2656 | ) -> Result<()> { |
| 2657 | 2657 | for (position, id) in affected.iter().enumerate() { |
| 2658 | − let parents = auto_merge::parents(repo, graph, *id)?; | |
| 2658 | + let parents = graph.parents_or_load(repo, *id)?; | |
| 2659 | 2659 | if parents.len() > 1 |
| 2660 | 2660 | && (position > 0 || removed || tree == Tree::CherryPick) |
| 2661 | 2661 | && !auto_merge::is_auto_merge(&repo.find_commit(*id)?.decode()?.into_owned()?) |
| @@ -268,7 +268,8 @@ impl HistoryGraph { · 267 unmodified lines | ||
| 268 | 268 | self.commits[index.as_usize()].id |
| 269 | 269 | } |
| 270 | 270 | |
| 271 | − pub(crate) fn parents(&self, index: CommitIndex) -> &[CommitIndex] { | |
| 271 | + /// Cached edges for traversal; an unloaded frontier has no known edges. | |
| 272 | + pub(crate) fn known_parents(&self, index: CommitIndex) -> &[CommitIndex] { | |
| 272 | 273 | let range = self.commits[index.as_usize()].parents.clone(); |
| 273 | 274 | &self.parents[range.start as usize..range.end as usize] |
| 274 | 275 | } |
| @@ -326,7 +327,7 @@ impl HistoryGraph { · 51 unmodified lines | ||
| 326 | 327 | if std::mem::replace(&mut seen[index.as_usize()], true) { |
| 327 | 328 | continue; |
| 328 | 329 | } |
| 329 | − pending.extend_from_slice(self.parents(index)); | |
| 330 | + pending.extend_from_slice(self.known_parents(index)); | |
| 330 | 331 | } |
| 331 | 332 | false |
| 332 | 333 | } |
| @@ -344,9 +345,23 @@ impl HistoryGraph { · 11 unmodified lines | ||
| 344 | 345 | .collect() |
| 345 | 346 | } |
| 346 | 347 | |
| 348 | + /// `None` means unloaded or unknown; an empty list means a loaded root or shallow boundary. | |
| 347 | 349 | pub(crate) fn parents_of(&self, id: ObjectId) -> Option<Vec<ObjectId>> { |
| 348 | 350 | let index = self.index(id)?; |
| 349 | − Some(self.parents(index).iter().map(|parent| self.id(*parent)).collect()) | |
| 351 | + (self.commits[index.as_usize()].state & NODE_LOADED != 0).then(|| { | |
| 352 | + self.known_parents(index) | |
| 353 | + .iter() | |
| 354 | + .map(|parent| self.id(*parent)) | |
| 355 | + .collect() | |
| 356 | + }) | |
| 357 | + } | |
| 358 | + | |
| 359 | + /// Read missing ancestry without changing the cached graph or its editable scope. | |
| 360 | + pub(crate) fn parents_or_load(&self, repo: &gix::Repository, commit_id: ObjectId) -> Result<Vec<ObjectId>> { | |
| 361 | + match self.parents_of(commit_id) { | |
| 362 | + Some(parents) => Ok(parents), | |
| 363 | + None => Ok(repo.find_commit(commit_id)?.parent_ids().map(gix::Id::detach).collect()), | |
| 364 | + } | |
| 350 | 365 | } |
| 351 | 366 | |
| 352 | 367 | pub(crate) fn commits_with_merge_descendants(&self) -> HashSet<ObjectId> { |
| @@ -367,7 +382,7 @@ impl HistoryGraph { · 14 unmodified lines | ||
| 367 | 382 | while let Some(index) = pending.pop() { |
| 368 | 383 | if ancestors.insert(index) { |
| 369 | 384 | pending.extend( |
| 370 | − self.parents(index) | |
| 385 | + self.known_parents(index) | |
| 371 | 386 | .iter() |
| 372 | 387 | .copied() |
| 373 | 388 | .filter(|parent| self.commits[parent.as_usize()].state & NODE_IN_VIEW != 0), |
| @@ -378,18 +393,18 @@ impl HistoryGraph { · 4 unmodified lines | ||
| 378 | 393 | } |
| 379 | 394 | |
| 380 | 395 | pub(crate) fn descendants_in_parent_order(&self, root: ObjectId) -> Option<Vec<ObjectId>> { |
| 381 | − let root = self.index(root)?; | |
| 382 | − if self.commits[root.as_usize()].state & NODE_IN_VIEW == 0 { | |
| 396 | + if !self.is_in_edit_scope(root) { | |
| 383 | 397 | return None; |
| 384 | 398 | } |
| 399 | + let root = self.index(root)?; | |
| 385 | 400 | let mut included = HashSet::from([root]); |
| 386 | 401 | loop { |
| 387 | 402 | let mut changed = false; |
| 388 | 403 | for index in 0..self.commits.len() { |
| 389 | 404 | let index = CommitIndex::new(index).expect("an existing graph index fits into u32"); |
| 390 | − if self.commits[index.as_usize()].state & NODE_IN_VIEW == 0 | |
| 405 | + if !self.is_in_edit_scope(self.id(index)) | |
| 391 | 406 | || included.contains(&index) |
| 392 | − || !self.parents(index).iter().any(|parent| included.contains(parent)) | |
| 407 | + || !self.known_parents(index).iter().any(|parent| included.contains(parent)) | |
| 393 | 408 | { |
| 394 | 409 | continue; |
| 395 | 410 | } |
| @@ -406,7 +421,7 @@ impl HistoryGraph { · 10 unmodified lines | ||
| 406 | 421 | for index in &included { |
| 407 | 422 | if out.contains(index) |
| 408 | 423 | || self |
| 409 | − .parents(*index) | |
| 424 | + .known_parents(*index) | |
| 410 | 425 | .iter() |
| 411 | 426 | .any(|parent| included.contains(parent) && !out.contains(parent)) |
| 412 | 427 | { |
| @@ -431,7 +446,7 @@ impl HistoryGraph { · 18 unmodified lines | ||
| 431 | 446 | continue; |
| 432 | 447 | } |
| 433 | 448 | self.commits[index.as_usize()].state |= NODE_IN_VIEW; |
| 434 | − pending.extend_from_slice(self.parents(index)); | |
| 449 | + pending.extend_from_slice(self.known_parents(index)); | |
| 435 | 450 | } |
| 436 | 451 | } |
| 437 | 452 | |
| @@ -447,7 +462,7 @@ impl HistoryGraph { · 9 unmodified lines | ||
| 447 | 462 | fn set_edit_scope(&mut self, view_tips: &[ObjectId], hidden_tips: &[ObjectId]) { |
| 448 | 463 | let (visible, boundary) = view_scope(view_tips, hidden_tips, |id, out| { |
| 449 | 464 | if let Some(index) = self.index(id) { |
| 450 | − out.extend(self.parents(index).iter().map(|parent| self.id(*parent))); | |
| 465 | + out.extend(self.known_parents(index).iter().map(|parent| self.id(*parent))); | |
| 451 | 466 | } |
| 452 | 467 | }); |
| 453 | 468 | self.edit_scope = visible; |
| @@ -455,7 +470,10 @@ impl HistoryGraph { · 1 unmodified line | ||
| 455 | 470 | } |
| 456 | 471 | |
| 457 | 472 | fn parent_ids(&self, index: CommitIndex) -> gix::traverse::commit::ParentIds { |
| 458 | − self.parents(index).iter().map(|parent| self.id(*parent)).collect() | |
| 473 | + self.known_parents(index) | |
| 474 | + .iter() | |
| 475 | + .map(|parent| self.id(*parent)) | |
| 476 | + .collect() | |
| 459 | 477 | } |
| 460 | 478 | |
| 461 | 479 | fn ensure_commit( |
| @@ -748,7 +766,7 @@ impl HistoryGraph { · 286 unmodified lines | ||
| 748 | 766 | propagated |= STALE; |
| 749 | 767 | flags[index.as_usize()] = propagated; |
| 750 | 768 | } |
| 751 | − for &parent in self.parents(index) { | |
| 769 | + for &parent in self.known_parents(index) { | |
| 752 | 770 | let parent_flags = &mut flags[parent.as_usize()]; |
| 753 | 771 | let previous = *parent_flags; |
| 754 | 772 | if previous & propagated != propagated { |
| @@ -924,7 +942,7 @@ impl HistoryGraph { · 169 unmodified lines | ||
| 924 | 942 | let stop = !should_store |
| 925 | 943 | && self.commits[index.as_usize()].state & NODE_COMPLETE != 0 |
| 926 | 944 | && (delta & EXPAND == 0 || was_stored && !expand.contains(&id)); |
| 927 | − let parent_indices = self.parents(index).to_vec(); | |
| 945 | + let parent_indices = self.known_parents(index).to_vec(); | |
| 928 | 946 | if should_store { |
| 929 | 947 | if let Some(names) = local_refs.get(&id) { |
| 930 | 948 | let tracked = resolve_tracking(repo, names)?; |
| @@ -1180,7 +1198,7 @@ fn hidden_frontier( · 249 unmodified lines | ||
| 1180 | 1198 | propagated |= STALE; |
| 1181 | 1199 | flags[index.as_usize()] = propagated; |
| 1182 | 1200 | } |
| 1183 | − let parents = graph.parents(index).to_vec(); | |
| 1201 | + let parents = graph.known_parents(index).to_vec(); | |
| 1184 | 1202 | for parent in parents { |
| 1185 | 1203 | let parent_id = graph.id(parent); |
| 1186 | 1204 | let parent = graph.ensure_commit(repo, cache, shallow, parent_id, &mut buf)?; |
| @@ -1381,7 +1399,7 @@ pub(crate) fn load( · 194 unmodified lines | ||
| 1381 | 1399 | if hidden.is_empty() { |
| 1382 | 1400 | graph.commits[index.as_usize()].state |= NODE_COMPLETE; |
| 1383 | 1401 | } |
| 1384 | − let parent_indices = graph.parents(index).to_vec(); | |
| 1402 | + let parent_indices = graph.known_parents(index).to_vec(); | |
| 1385 | 1403 | let parent_ids = graph.parent_ids(index); |
| 1386 | 1404 | let generation = graph.commits[index.as_usize()].generation(); |
| 1387 | 1405 | if should_emit && let Some(names) = local_refs.get(&id) { |
| @@ -3355,6 +3373,33 @@ mod tests { · 1967 unmodified lines | ||
| 3355 | 3373 | |
| 3356 | 3374 | assert!(graph.is_in_edit_scope(tip), "the requested commit is in the edit scope"); |
| 3357 | 3375 | assert!(graph.index(parent).is_some(), "the requested commit's parent is known"); |
| 3376 | + assert_eq!(graph.parents_of(tip), Some(vec![parent]), "loaded edges are available"); | |
| 3377 | + assert_eq!( | |
| 3378 | + graph.parents_of(parent), | |
| 3379 | + None, | |
| 3380 | + "an unloaded frontier is not a root commit" | |
| 3381 | + ); | |
| 3382 | + let root = repo.rev_parse_single("topic^^")?.detach(); | |
| 3383 | + assert_eq!( | |
| 3384 | + graph.parents_or_load(&repo, parent)?, | |
| 3385 | + [root], | |
| 3386 | + "a frontier can be read on demand" | |
| 3387 | + ); | |
| 3388 | + assert_eq!( | |
| 3389 | + graph.parents_of(parent), | |
| 3390 | + None, | |
| 3391 | + "an ancestry read does not expand the cache" | |
| 3392 | + ); | |
| 3393 | + assert_eq!( | |
| 3394 | + HistoryGraph::for_commits(&repo, &[root])?.parents_of(root), | |
| 3395 | + Some(Vec::new()), | |
| 3396 | + "a loaded root has a known empty parent list" | |
| 3397 | + ); | |
| 3398 | + assert_eq!( | |
| 3399 | + graph.descendants_in_parent_order(parent), | |
| 3400 | + None, | |
| 3401 | + "interning an ancestor does not make it an editable rewrite root" | |
| 3402 | + ); | |
| 3358 | 3403 | assert!( |
| 3359 | 3404 | !graph.is_in_edit_scope(parent), |
| 3360 | 3405 | "an interned parent stays outside the explicit edit scope" |
| @@ -3658,7 +3703,7 @@ mod tests { · 297 unmodified lines | ||
| 3658 | 3703 | let repo = crate::test_repository::open(fixture.path())?; |
| 3659 | 3704 | let index = graph.index(main).expect("the tracking tip was scheduled"); |
| 3660 | 3705 | let fake_parent = graph.intern(id(255)).expect("the small test graph fits in u32"); |
| 3661 | − let mut parents = graph.parents(index).to_vec(); | |
| 3706 | + let mut parents = graph.known_parents(index).to_vec(); | |
| 3662 | 3707 | parents.push(fake_parent); |
| 3663 | 3708 | let start = graph.parents.len() as u32; |
| 3664 | 3709 | graph.parents.extend(parents); |
| @@ -998,13 +998,13 @@ impl Overview { · 997 unmodified lines | ||
| 998 | 998 | if !included.insert(index) { |
| 999 | 999 | break; |
| 1000 | 1000 | } |
| 1001 | − current = graph.parents(index).first().copied(); | |
| 1001 | + current = graph.known_parents(index).first().copied(); | |
| 1002 | 1002 | } |
| 1003 | 1003 | } |
| 1004 | 1004 | let mut children = vec![Vec::new(); graph.commit_count()]; |
| 1005 | 1005 | for child in included.iter().copied() { |
| 1006 | 1006 | if let Some(parent) = graph |
| 1007 | − .parents(child) | |
| 1007 | + .known_parents(child) | |
| 1008 | 1008 | .first() |
| 1009 | 1009 | .copied() |
| 1010 | 1010 | .filter(|parent| included.contains(parent)) |
| @@ -1019,7 +1019,7 @@ impl Overview { · 8 unmodified lines | ||
| 1019 | 1019 | anchors.contains(index) |
| 1020 | 1020 | || children[index.as_usize()].len() != 1 |
| 1021 | 1021 | || graph |
| 1022 | − .parents(*index) | |
| 1022 | + .known_parents(*index) | |
| 1023 | 1023 | .first() |
| 1024 | 1024 | .is_none_or(|parent| !included.contains(parent)) |
| 1025 | 1025 | }) |
| @@ -1058,7 +1058,7 @@ impl Overview { · 32 unmodified lines | ||
| 1058 | 1058 | let mut roots = Vec::new(); |
| 1059 | 1059 | for child in 0..nodes.len() { |
| 1060 | 1060 | let mut hidden = Vec::new(); |
| 1061 | − let mut parent = graph.parents(nodes[child].commit).first().copied(); | |
| 1061 | + let mut parent = graph.known_parents(nodes[child].commit).first().copied(); | |
| 1062 | 1062 | while let Some(index) = parent.filter(|index| included.contains(index)) { |
| 1063 | 1063 | if let Some(parent_node) = by_commit.get(&index).copied() { |
| 1064 | 1064 | nodes[child].parent = Some(parent_node); |
| @@ -1071,7 +1071,7 @@ impl Overview { · 6 unmodified lines | ||
| 1071 | 1071 | break; |
| 1072 | 1072 | } |
| 1073 | 1073 | hidden.push(index); |
| 1074 | − parent = graph.parents(index).first().copied(); | |
| 1074 | + parent = graph.known_parents(index).first().copied(); | |
| 1075 | 1075 | } |
| 1076 | 1076 | if nodes[child].parent.is_none() { |
| 1077 | 1077 | roots.push(child); |
| @@ -1128,13 +1128,13 @@ impl Overlay { · 50 unmodified lines | ||
| 1128 | 1128 | continue; |
| 1129 | 1129 | } |
| 1130 | 1130 | total += 1; |
| 1131 | − pending.extend_from_slice(graph.parents(index)); | |
| 1131 | + pending.extend_from_slice(graph.known_parents(index)); | |
| 1132 | 1132 | } |
| 1133 | 1133 | let mut first_parent = vec![false; graph.commit_count()]; |
| 1134 | 1134 | let mut current = Some(selected_commit); |
| 1135 | 1135 | while let Some(index) = current { |
| 1136 | 1136 | first_parent[index.as_usize()] = true; |
| 1137 | − current = graph.parents(index).first().copied(); | |
| 1137 | + current = graph.known_parents(index).first().copied(); | |
| 1138 | 1138 | } |
| 1139 | 1139 | let mut counts = vec![None; overview.nodes.len()]; |
| 1140 | 1140 | counts[selected] = Some(total); |
| @@ -1187,7 +1187,7 @@ impl Overlay { · 46 unmodified lines | ||
| 1187 | 1187 | continue; |
| 1188 | 1188 | } |
| 1189 | 1189 | count += 1; |
| 1190 | − pending.extend_from_slice(graph.parents(index)); | |
| 1190 | + pending.extend_from_slice(graph.known_parents(index)); | |
| 1191 | 1191 | } |
| 1192 | 1192 | self.counts[node] = Some(count); |
| 1193 | 1193 | } |