History

Distinguish unloaded graph frontiers from root commits

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.

Codex committed 2 months ago

Commit dd22c8b1 parent 47fd1ed5 files · +81 −46Browse files

Unified · Split

5 files
  1. gix-tix/spec.md +3 −0
  2. gix-tix/src/edit/auto_merge/mod.rs +2 −15
  3. gix-tix/src/edit/rebase.rs +6 −6
  4. gix-tix/src/history.rs +62 −17
  5. gix-tix/src/ref_tree.rs +8 −8
gix-tix/spec.md+3−0
@@ -1176,6 +1176,9 @@ views. · 1175 unmodified lines
1176 results by immutable commit ID. Idle application state retains detached recipes1176 results by immutable commit ID. Idle application state retains detached recipes
1177 and picker data only. Pin-consuming checkouts may reload the current projection1177 and picker data only. Pin-consuming checkouts may reload the current projection
1178 to determine which pins must be retained.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### Reviews1183### Reviews
1181 1184
gix-tix/src/edit/auto_merge/mod.rs+2−15
@@ -309,20 +309,6 @@ pub(crate) struct Preparation { · 308 unmodified lines
309 pub eager: HashSet<ObjectId>,309 pub eager: HashSet<ObjectId>,
310}310}
311 311
312pub(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/// Only the checkout's ordinary first-parent path requires conflict materialization.312/// Only the checkout's ordinary first-parent path requires conflict materialization.
327/// Inputs beneath an AutoMerge can instead remain pending and be muted.313/// Inputs beneath an AutoMerge can instead remain pending and be muted.
328pub(crate) fn checkout_path(314pub(crate) fn checkout_path(
@@ -900,7 +886,8 @@ pub(crate) fn expand_plan( · 571 unmodified lines
900 }886 }
901 let extra: Vec<_> = affected.into_iter().filter(|id| !original.contains(id)).collect();887 let extra: Vec<_> = affected.into_iter().filter(|id| !original.contains(id)).collect();
902 for &commit_id in &extra {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 .first()891 .first()
905 .copied()892 .copied()
906 .context("a pending input has no parent")?;893 .context("a pending input has no parent")?;
gix-tix/src/edit/rebase.rs+6−6
@@ -1267,7 +1267,7 @@ fn perform_inner( · 1266 unmodified lines
1267 pending.retain(|id| Some(*id) != root);1267 pending.retain(|id| Some(*id) != root);
1268 }1268 }
1269 for old_id in pending {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 let mut commit = match replacement.as_ref().filter(|_| Some(old_id) == root) {1271 let mut commit = match replacement.as_ref().filter(|_| Some(old_id) == root) {
1272 Some(commit) => commit.clone(),1272 Some(commit) => commit.clone(),
1273 None => repo1273 None => repo
@@ -1546,7 +1546,7 @@ pub(super) fn finish_review_with_progress( · 272 unmodified lines
1546 )?;1546 )?;
1547 }1547 }
1548 for id in review_ids.iter().chain(&natural_ids) {1548 for id in review_ids.iter().chain(&natural_ids) {
1549 if auto_merge::parents(&repo, graph, *id)?.len() > 11549 if graph.parents_or_load(&repo, *id)?.len() > 1
1550 && !auto_merge::is_auto_merge(&repo.find_commit(*id)?.decode()?.into_owned()?)1550 && !auto_merge::is_auto_merge(&repo.find_commit(*id)?.decode()?.into_owned()?)
1551 {1551 {
1552 anyhow::bail!("review finish cannot rewrite merge descendants");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 rewritten.insert(tip, Some(finished_review));1608 rewritten.insert(tip, Some(finished_review));
1609 for old in natural_ids {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 let mut commit = repo.find_commit(old)?.decode()?.into_owned()?;1611 let mut commit = repo.find_commit(old)?.decode()?.into_owned()?;
1612 if auto_merge::is_auto_merge(&commit) {1612 if auto_merge::is_auto_merge(&commit) {
1613 let eager = conflict.is_none() && auto.eager.contains(&old);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 if automatic && step.squash.contains(&id) {1772 if automatic && step.squash.contains(&id) {
1773 anyhow::bail!("an AutoMerge cannot be squashed");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 anyhow::bail!("merge commits cannot be picked by the rebase editor");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 let graph_parents = match step.commit {1945 let graph_parents = match step.commit {
1946 PlanCommit::Pick(id) | PlanCommit::Copy(id) | PlanCommit::Resolved(id) => {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 PlanCommit::Empty(_) => vec![parent],1949 PlanCommit::Empty(_) => vec![parent],
1950 };1950 };
@@ -2655,7 +2655,7 @@ fn validate( · 704 unmodified lines
2655 tree: Tree,2655 tree: Tree,
2656) -> Result<()> {2656) -> Result<()> {
2657 for (position, id) in affected.iter().enumerate() {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 if parents.len() > 12659 if parents.len() > 1
2660 && (position > 0 || removed || tree == Tree::CherryPick)2660 && (position > 0 || removed || tree == Tree::CherryPick)
2661 && !auto_merge::is_auto_merge(&repo.find_commit(*id)?.decode()?.into_owned()?)2661 && !auto_merge::is_auto_merge(&repo.find_commit(*id)?.decode()?.into_owned()?)
gix-tix/src/history.rs+62−17
@@ -268,7 +268,8 @@ impl HistoryGraph { · 267 unmodified lines
268 self.commits[index.as_usize()].id268 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 let range = self.commits[index.as_usize()].parents.clone();273 let range = self.commits[index.as_usize()].parents.clone();
273 &self.parents[range.start as usize..range.end as usize]274 &self.parents[range.start as usize..range.end as usize]
274 }275 }
@@ -326,7 +327,7 @@ impl HistoryGraph { · 51 unmodified lines
326 if std::mem::replace(&mut seen[index.as_usize()], true) {327 if std::mem::replace(&mut seen[index.as_usize()], true) {
327 continue;328 continue;
328 }329 }
329 pending.extend_from_slice(self.parents(index));330 pending.extend_from_slice(self.known_parents(index));
330 }331 }
331 false332 false
332 }333 }
@@ -344,9 +345,23 @@ impl HistoryGraph { · 11 unmodified lines
344 .collect()345 .collect()
345 }346 }
346 347
348 /// `None` means unloaded or unknown; an empty list means a loaded root or shallow boundary.
347 pub(crate) fn parents_of(&self, id: ObjectId) -> Option<Vec<ObjectId>> {349 pub(crate) fn parents_of(&self, id: ObjectId) -> Option<Vec<ObjectId>> {
348 let index = self.index(id)?;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 pub(crate) fn commits_with_merge_descendants(&self) -> HashSet<ObjectId> {367 pub(crate) fn commits_with_merge_descendants(&self) -> HashSet<ObjectId> {
@@ -367,7 +382,7 @@ impl HistoryGraph { · 14 unmodified lines
367 while let Some(index) = pending.pop() {382 while let Some(index) = pending.pop() {
368 if ancestors.insert(index) {383 if ancestors.insert(index) {
369 pending.extend(384 pending.extend(
370 self.parents(index)385 self.known_parents(index)
371 .iter()386 .iter()
372 .copied()387 .copied()
373 .filter(|parent| self.commits[parent.as_usize()].state & NODE_IN_VIEW != 0),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 pub(crate) fn descendants_in_parent_order(&self, root: ObjectId) -> Option<Vec<ObjectId>> {395 pub(crate) fn descendants_in_parent_order(&self, root: ObjectId) -> Option<Vec<ObjectId>> {
381 let root = self.index(root)?;396 if !self.is_in_edit_scope(root) {
382 if self.commits[root.as_usize()].state & NODE_IN_VIEW == 0 {
383 return None;397 return None;
384 }398 }
399 let root = self.index(root)?;
385 let mut included = HashSet::from([root]);400 let mut included = HashSet::from([root]);
386 loop {401 loop {
387 let mut changed = false;402 let mut changed = false;
388 for index in 0..self.commits.len() {403 for index in 0..self.commits.len() {
389 let index = CommitIndex::new(index).expect("an existing graph index fits into u32");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 == 0405 if !self.is_in_edit_scope(self.id(index))
391 || included.contains(&index)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 continue;409 continue;
395 }410 }
@@ -406,7 +421,7 @@ impl HistoryGraph { · 10 unmodified lines
406 for index in &included {421 for index in &included {
407 if out.contains(index)422 if out.contains(index)
408 || self423 || self
409 .parents(*index)424 .known_parents(*index)
410 .iter()425 .iter()
411 .any(|parent| included.contains(parent) && !out.contains(parent))426 .any(|parent| included.contains(parent) && !out.contains(parent))
412 {427 {
@@ -431,7 +446,7 @@ impl HistoryGraph { · 18 unmodified lines
431 continue;446 continue;
432 }447 }
433 self.commits[index.as_usize()].state |= NODE_IN_VIEW;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 fn set_edit_scope(&mut self, view_tips: &[ObjectId], hidden_tips: &[ObjectId]) {462 fn set_edit_scope(&mut self, view_tips: &[ObjectId], hidden_tips: &[ObjectId]) {
448 let (visible, boundary) = view_scope(view_tips, hidden_tips, |id, out| {463 let (visible, boundary) = view_scope(view_tips, hidden_tips, |id, out| {
449 if let Some(index) = self.index(id) {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 self.edit_scope = visible;468 self.edit_scope = visible;
@@ -455,7 +470,10 @@ impl HistoryGraph { · 1 unmodified line
455 }470 }
456 471
457 fn parent_ids(&self, index: CommitIndex) -> gix::traverse::commit::ParentIds {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 fn ensure_commit(479 fn ensure_commit(
@@ -748,7 +766,7 @@ impl HistoryGraph { · 286 unmodified lines
748 propagated |= STALE;766 propagated |= STALE;
749 flags[index.as_usize()] = propagated;767 flags[index.as_usize()] = propagated;
750 }768 }
751 for &parent in self.parents(index) {769 for &parent in self.known_parents(index) {
752 let parent_flags = &mut flags[parent.as_usize()];770 let parent_flags = &mut flags[parent.as_usize()];
753 let previous = *parent_flags;771 let previous = *parent_flags;
754 if previous & propagated != propagated {772 if previous & propagated != propagated {
@@ -924,7 +942,7 @@ impl HistoryGraph { · 169 unmodified lines
924 let stop = !should_store942 let stop = !should_store
925 && self.commits[index.as_usize()].state & NODE_COMPLETE != 0943 && self.commits[index.as_usize()].state & NODE_COMPLETE != 0
926 && (delta & EXPAND == 0 || was_stored && !expand.contains(&id));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 if should_store {946 if should_store {
929 if let Some(names) = local_refs.get(&id) {947 if let Some(names) = local_refs.get(&id) {
930 let tracked = resolve_tracking(repo, names)?;948 let tracked = resolve_tracking(repo, names)?;
@@ -1180,7 +1198,7 @@ fn hidden_frontier( · 249 unmodified lines
1180 propagated |= STALE;1198 propagated |= STALE;
1181 flags[index.as_usize()] = propagated;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 for parent in parents {1202 for parent in parents {
1185 let parent_id = graph.id(parent);1203 let parent_id = graph.id(parent);
1186 let parent = graph.ensure_commit(repo, cache, shallow, parent_id, &mut buf)?;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 if hidden.is_empty() {1399 if hidden.is_empty() {
1382 graph.commits[index.as_usize()].state |= NODE_COMPLETE;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 let parent_ids = graph.parent_ids(index);1403 let parent_ids = graph.parent_ids(index);
1386 let generation = graph.commits[index.as_usize()].generation();1404 let generation = graph.commits[index.as_usize()].generation();
1387 if should_emit && let Some(names) = local_refs.get(&id) {1405 if should_emit && let Some(names) = local_refs.get(&id) {
@@ -3355,6 +3373,33 @@ mod tests { · 1967 unmodified lines
3355 3373
3356 assert!(graph.is_in_edit_scope(tip), "the requested commit is in the edit scope");3374 assert!(graph.is_in_edit_scope(tip), "the requested commit is in the edit scope");
3357 assert!(graph.index(parent).is_some(), "the requested commit's parent is known");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 assert!(3403 assert!(
3359 !graph.is_in_edit_scope(parent),3404 !graph.is_in_edit_scope(parent),
3360 "an interned parent stays outside the explicit edit scope"3405 "an interned parent stays outside the explicit edit scope"
@@ -3658,7 +3703,7 @@ mod tests { · 297 unmodified lines
3658 let repo = crate::test_repository::open(fixture.path())?;3703 let repo = crate::test_repository::open(fixture.path())?;
3659 let index = graph.index(main).expect("the tracking tip was scheduled");3704 let index = graph.index(main).expect("the tracking tip was scheduled");
3660 let fake_parent = graph.intern(id(255)).expect("the small test graph fits in u32");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 parents.push(fake_parent);3707 parents.push(fake_parent);
3663 let start = graph.parents.len() as u32;3708 let start = graph.parents.len() as u32;
3664 graph.parents.extend(parents);3709 graph.parents.extend(parents);
gix-tix/src/ref_tree.rs+8−8
@@ -998,13 +998,13 @@ impl Overview { · 997 unmodified lines
998 if !included.insert(index) {998 if !included.insert(index) {
999 break;999 break;
1000 }1000 }
1001 current = graph.parents(index).first().copied();1001 current = graph.known_parents(index).first().copied();
1002 }1002 }
1003 }1003 }
1004 let mut children = vec![Vec::new(); graph.commit_count()];1004 let mut children = vec![Vec::new(); graph.commit_count()];
1005 for child in included.iter().copied() {1005 for child in included.iter().copied() {
1006 if let Some(parent) = graph1006 if let Some(parent) = graph
1007 .parents(child)1007 .known_parents(child)
1008 .first()1008 .first()
1009 .copied()1009 .copied()
1010 .filter(|parent| included.contains(parent))1010 .filter(|parent| included.contains(parent))
@@ -1019,7 +1019,7 @@ impl Overview { · 8 unmodified lines
1019 anchors.contains(index)1019 anchors.contains(index)
1020 || children[index.as_usize()].len() != 11020 || children[index.as_usize()].len() != 1
1021 || graph1021 || graph
1022 .parents(*index)1022 .known_parents(*index)
1023 .first()1023 .first()
1024 .is_none_or(|parent| !included.contains(parent))1024 .is_none_or(|parent| !included.contains(parent))
1025 })1025 })
@@ -1058,7 +1058,7 @@ impl Overview { · 32 unmodified lines
1058 let mut roots = Vec::new();1058 let mut roots = Vec::new();
1059 for child in 0..nodes.len() {1059 for child in 0..nodes.len() {
1060 let mut hidden = Vec::new();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 while let Some(index) = parent.filter(|index| included.contains(index)) {1062 while let Some(index) = parent.filter(|index| included.contains(index)) {
1063 if let Some(parent_node) = by_commit.get(&index).copied() {1063 if let Some(parent_node) = by_commit.get(&index).copied() {
1064 nodes[child].parent = Some(parent_node);1064 nodes[child].parent = Some(parent_node);
@@ -1071,7 +1071,7 @@ impl Overview { · 6 unmodified lines
1071 break;1071 break;
1072 }1072 }
1073 hidden.push(index);1073 hidden.push(index);
1074 parent = graph.parents(index).first().copied();1074 parent = graph.known_parents(index).first().copied();
1075 }1075 }
1076 if nodes[child].parent.is_none() {1076 if nodes[child].parent.is_none() {
1077 roots.push(child);1077 roots.push(child);
@@ -1128,13 +1128,13 @@ impl Overlay { · 50 unmodified lines
1128 continue;1128 continue;
1129 }1129 }
1130 total += 1;1130 total += 1;
1131 pending.extend_from_slice(graph.parents(index));1131 pending.extend_from_slice(graph.known_parents(index));
1132 }1132 }
1133 let mut first_parent = vec![false; graph.commit_count()];1133 let mut first_parent = vec![false; graph.commit_count()];
1134 let mut current = Some(selected_commit);1134 let mut current = Some(selected_commit);
1135 while let Some(index) = current {1135 while let Some(index) = current {
1136 first_parent[index.as_usize()] = true;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 let mut counts = vec![None; overview.nodes.len()];1139 let mut counts = vec![None; overview.nodes.len()];
1140 counts[selected] = Some(total);1140 counts[selected] = Some(total);
@@ -1187,7 +1187,7 @@ impl Overlay { · 46 unmodified lines
1187 continue;1187 continue;
1188 }1188 }
1189 count += 1;1189 count += 1;
1190 pending.extend_from_slice(graph.parents(index));1190 pending.extend_from_slice(graph.known_parents(index));
1191 }1191 }
1192 self.counts[node] = Some(count);1192 self.counts[node] = Some(count);
1193 }1193 }