revset: forks() function to get all visible commits with multiple children

`forks()` is especially useful in combination with `visible_heads()` and
`merges()` to get a topological overview of the revset.

The initial implementation of this took a revset argument, with
`forks()` mapping to `forks(all())`. After a discussion[1] this was
decided against, since it's not obvious whether it should trace through
nodes in the global graph but not the subgraph `x`.

[1]: https://github.com/jj-vcs/jj/pull/9678#discussion_r3467851229
This commit is contained in:
Jonas Carpay
2026-06-25 11:26:38 +09:00
committed by Yuya Nishihara
parent 6a4ae89551
commit e0ef08ed38
6 changed files with 102 additions and 0 deletions

View File

@@ -49,6 +49,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
existed in a stack, those rewritten revisions and their descendants wouldn't
always be rebased. Note that immutable descendants will not be rebased.
* Add a `forks()` revset function that yields all commits with more than 1 child.
### Fixed bugs
* On Windows, querying a path's file identity no longer follows symbolic links,

View File

@@ -361,6 +361,8 @@ revsets (expressions) as arguments.
* `merges()`: Merge commits.
* `forks()`: Fork commits, i.e. those with more than 1 child.
* `description(pattern)`: Commits that have a description matching the given
[string pattern](#string-patterns).

View File

@@ -17,6 +17,7 @@ use std::cmp::Ordering;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::collections::HashSet;
use std::collections::hash_map::HashMap;
use std::convert::Infallible;
use std::fmt;
use std::iter;
@@ -970,6 +971,26 @@ impl EvaluationContext<'_> {
});
Ok(Box::new(EagerRevset { positions }))
}
ResolvedExpression::Forks { heads } => {
let head_positions = self
.evaluate(heads)?
.positions()
.attach(index)
.try_collect()?;
let mut child_counts: HashMap<GlobalCommitPosition, u32> = HashMap::new();
let walk = RevWalkBuilder::new(index)
.wanted_heads(head_positions)
.ancestors()
.detach()
.filter_map(move |index: &CompositeIndex, pos| {
let is_fork = child_counts.remove(&pos).unwrap_or(0) >= 2;
for parent in index.commits().entry_by_pos(pos).parent_positions() {
*child_counts.entry(parent).or_insert(0) += 1;
}
is_fork.then_some(pos)
});
Ok(Box::new(RevWalkRevset { walk }))
}
ResolvedExpression::ForkPoint(expression) => {
let expression_set = self.evaluate(expression)?;
let mut expression_positions_iter = expression_set.positions().attach(index);

View File

@@ -313,6 +313,7 @@ pub enum RevsetExpression<St: ExpressionState> {
},
Roots(Arc<Self>),
ForkPoint(Arc<Self>),
Forks,
Bisect(Arc<Self>),
HasSize {
candidates: Arc<Self>,
@@ -377,6 +378,10 @@ impl<St: ExpressionState> RevsetExpression<St> {
Arc::new(Self::Root)
}
pub fn forks() -> Arc<Self> {
Arc::new(Self::Forks)
}
pub fn commit(commit_id: CommitId) -> Arc<Self> {
Self::commits(vec![commit_id])
}
@@ -758,6 +763,9 @@ pub enum ResolvedExpression {
filter: Option<ResolvedPredicateExpression>,
},
Roots(Box<Self>),
Forks {
heads: Box<Self>,
},
ForkPoint(Box<Self>),
Bisect(Box<Self>),
HasSize {
@@ -1000,6 +1008,10 @@ static BUILTIN_FUNCTION_MAP: LazyLock<HashMap<&str, RevsetFunction>> = LazyLock:
RevsetFilterPredicate::ParentCount(2..u32::MAX),
))
});
map.insert("forks", |_diagnostics, function, _context| {
function.expect_no_arguments()?;
Ok(RevsetExpression::forks())
});
map.insert("description", |diagnostics, function, _context| {
let [arg] = function.expect_exact_arguments()?;
let expr = expect_string_expression(diagnostics, arg)?;
@@ -1559,6 +1571,7 @@ fn try_transform_expression<St: ExpressionState, E>(
RevsetExpression::Roots(candidates) => {
transform_rec(candidates, pre, post)?.map(RevsetExpression::Roots)
}
RevsetExpression::Forks => None,
RevsetExpression::ForkPoint(expression) => {
transform_rec(expression, pre, post)?.map(RevsetExpression::ForkPoint)
}
@@ -1805,6 +1818,7 @@ where
let roots = folder.fold_expression(roots)?;
RevsetExpression::Roots(roots).into()
}
RevsetExpression::Forks => RevsetExpression::Forks.into(),
RevsetExpression::ForkPoint(expression) => {
let expression = folder.fold_expression(expression)?;
RevsetExpression::ForkPoint(expression).into()
@@ -3154,6 +3168,9 @@ impl VisibilityResolutionContext<'_> {
RevsetExpression::Roots(candidates) => {
ResolvedExpression::Roots(self.resolve(candidates).into())
}
RevsetExpression::Forks => ResolvedExpression::Forks {
heads: self.resolve_visible_heads_or_referenced().into(),
},
RevsetExpression::ForkPoint(expression) => {
ResolvedExpression::ForkPoint(self.resolve(expression).into())
}
@@ -3293,6 +3310,7 @@ impl VisibilityResolutionContext<'_> {
| RevsetExpression::Heads(_)
| RevsetExpression::HeadsRange { .. }
| RevsetExpression::Roots(_)
| RevsetExpression::Forks
| RevsetExpression::ForkPoint(_)
| RevsetExpression::Bisect(_)
| RevsetExpression::HasSize { .. }

View File

@@ -1419,6 +1419,64 @@ fn test_evaluate_expression_roots() {
);
}
#[test]
fn test_evaluate_expression_forks() {
let test_repo = TestRepo::init();
let repo = &test_repo.repo;
let root_commit = repo.store().root_commit();
let mut tx = repo.start_transaction();
let mut_repo = tx.repo_mut();
/*
* 9 <- merge
* /|\
* 6 7 8
* \|/
* 5 <- 3-way fork
* /|
* 3 4 <- not a fork
* |/
* 1 2 <- 2-way fork
* |/
* 0 <- 2-way fork from root
*/
let commit1 = write_random_commit(mut_repo);
let commit2 = write_random_commit(mut_repo);
let commit3 = write_random_commit_with_parents(mut_repo, &[&commit1]);
let commit4 = write_random_commit_with_parents(mut_repo, &[&commit1]);
let commit5 = write_random_commit_with_parents(mut_repo, &[&commit3, &commit4]);
let commit6 = write_random_commit_with_parents(mut_repo, &[&commit5]);
let commit7 = write_random_commit_with_parents(mut_repo, &[&commit5]);
let commit8 = write_random_commit_with_parents(mut_repo, &[&commit5]);
let _commit9 = write_random_commit_with_parents(mut_repo, &[&commit6, &commit7, &commit8]);
// In the above graph, the forks are 0, 1, and 5
assert_eq!(
resolve_commit_ids(mut_repo, "forks()"),
vec![
commit5.id().clone(),
commit1.id().clone(),
root_commit.id().clone(),
]
);
// 5 is a merge and a fork
assert_eq!(
resolve_commit_ids(mut_repo, "merges() & forks()"),
vec![commit5.id().clone()]
);
mut_repo.record_abandoned_commit(&commit2);
mut_repo.record_abandoned_commit(&commit7);
mut_repo.rebase_descendants().block_on().unwrap();
// After abandoning 2 and 7, only the root is no longer a fork.
assert_eq!(
resolve_commit_ids(mut_repo, "forks()"),
vec![commit5.id().clone(), commit1.id().clone(),]
);
}
#[test]
fn test_evaluate_expression_parents() -> TestResult {
let test_workspace = TestWorkspace::init();

View File

@@ -88,6 +88,7 @@ fn arb_expression(
Just(RevsetExpression::all()),
Just(RevsetExpression::visible_heads()),
Just(RevsetExpression::root()),
Just(RevsetExpression::forks()),
proptest::sample::subsequence(known_commits, 1..=5.min(max_commits))
.prop_map(RevsetExpression::commits),
// Use merges() as a filter that isn't constant. Since we don't have an