swh_graph_stdlib/vcs.rs
1// Copyright (C) 2024-2026 The Software Heritage developers
2// See the AUTHORS file at the top-level directory of this distribution
3// License: GNU General Public License version 3, or any later version
4// See top-level LICENSE file for more information
5
6//! Version control system (VCS) functions.
7
8use std::collections::VecDeque;
9
10use anyhow::{Result, ensure};
11use dsi_progress_logger::{ProgressLog, concurrent_progress_logger};
12use rayon::prelude::*;
13
14use swh_graph::graph::*;
15use swh_graph::labels::{EdgeLabel, LabelNameId};
16use swh_graph::properties;
17use swh_graph::views::Subgraph;
18use swh_graph::{NodeConstraint, NodeType};
19
20use crate::collections::{AdaptiveNodeSet, NodeSet, ReadNodeSet};
21use crate::{iter_nodes, peel_rel};
22
23/// Names of references ("branches") that are considered to be pointing to the
24/// HEAD revision in a VCS, by [find_head_rev] below. Names are tried in order,
25/// when attempting to identify the HEAD revision.
26pub const HEAD_REF_NAMES: [&str; 2] = ["refs/heads/main", "refs/heads/master"];
27
28/// Given a graph and a snapshot node in it, return the node id of the revision
29/// pointed by the HEAD branch, if it exists.
30pub fn find_head_rev<G>(graph: &G, snp: NodeId) -> Result<Option<NodeId>>
31where
32 G: SwhLabeledForwardGraph + SwhGraphWithProperties,
33 <G as SwhGraphWithProperties>::LabelNames: properties::LabelNames,
34 <G as SwhGraphWithProperties>::Maps: properties::Maps,
35{
36 let props = graph.properties();
37 let head_ref_name_ids: Vec<LabelNameId> = HEAD_REF_NAMES
38 .into_iter()
39 .filter_map(|name| props.label_name_id(name.as_bytes()).ok())
40 // Note, we ignore errors on purpose here, because some ref names that
41 // do exist on the SWH graph might be missing in user-provided graphs,
42 // and will fail [label_name_id] call.
43 .collect();
44 find_head_rev_by_refs(graph, snp, &head_ref_name_ids)
45}
46
47/// Same as [find_head_rev], but with the ability to configure which branch
48/// names correspond to the HEAD revision.
49///
50/// Note: this function is also more efficient than [find_head_rev], because
51/// branch names are pre-resolved to integers once (before calling this
52/// function) and compared as integers. See the source code of [find_head_rev]
53/// for an example of how to translate a given set of branch names (like
54/// [HEAD_REF_NAMES]) to label-name IDs for this function.
55pub fn find_head_rev_by_refs<G>(
56 graph: &G,
57 snp: NodeId,
58 ref_name_ids: &[LabelNameId],
59) -> Result<Option<NodeId>>
60where
61 G: SwhLabeledForwardGraph + SwhGraphWithProperties,
62 <G as SwhGraphWithProperties>::Maps: properties::Maps,
63 <G as SwhGraphWithProperties>::LabelNames: properties::LabelNames,
64{
65 let props = graph.properties();
66 let node_type = props.node_type(snp);
67 ensure!(
68 node_type == NodeType::Snapshot,
69 "Cannot find head revision of {}: not a snapshot",
70 graph.properties().swhid(snp),
71 );
72 for (succ, labels) in graph.labeled_successors(snp) {
73 let node_type = props.node_type(succ);
74 if node_type != NodeType::Revision && node_type != NodeType::Release {
75 continue;
76 }
77 for label in labels {
78 #[allow(clippy::collapsible_if)]
79 if let EdgeLabel::Branch(branch) = label {
80 if ref_name_ids.contains(&branch.label_name_id()) {
81 return Ok(Some(succ));
82 }
83 }
84 }
85 }
86 Ok(None)
87}
88
89/// Returns all revisions that have no parent revision.
90pub fn list_root_revisions<G>(graph: &G) -> Result<Vec<NodeId>>
91where
92 G: SwhForwardGraph + SwhGraphWithProperties<Maps: properties::Maps> + Sync,
93{
94 let graph = Subgraph::with_node_constraint(graph, "rev".parse().unwrap());
95
96 let mut pl = concurrent_progress_logger!(
97 item_name = "rev",
98 display_memory = false,
99 expected_updates = graph.actual_num_nodes().ok(),
100 );
101 pl.start("Listing initial revs...");
102 Ok(graph
103 .par_iter_nodes(pl)
104 .filter(|&node| graph.successors(node).next().is_none())
105 .collect())
106}
107
108/// Revision listing options (for [`list_revisions_with`]).
109///
110/// All filters are AND-ed together. Following `git-rev-list` semantics, filters come in two
111/// kinds:
112///
113/// - *Traversal filters* prune the traversal: a filtered-out commit is not listed, and none of
114/// its ancestors are visited (nor listed, unless reachable via another path). The only
115/// traversal filter is [since](Self::since).
116///
117/// - *Output filters* only affect which commits are listed: traversal continues through
118/// filtered-out commits, so their ancestors are still visited and listed. For example,
119/// `max_parents: 1` lists all non-merge ancestors, including those reachable only through
120/// merges. Output filters are [min_parents](Self::min_parents),
121/// [max_parents](Self::max_parents), and [until](Self::until).
122///
123/// All timestamps are specified as seconds since the UNIX epoch. All time-based filters apply to
124/// committer date by default (rather than author date). Revisions with no known committer date
125/// pass all time-based filters (i.e., they are never filtered out by [since](Self::since) or
126/// [until](Self::until)).
127#[derive(Default)]
128pub struct RevListOptions {
129 /// Only list revisions with at least this amount of "parents" (i.e., successors in the
130 /// forward graph).
131 pub min_parents: Option<usize>,
132
133 /// Only list revisions with at most this amount of "parents" (i.e., successors in the
134 /// forward graph).
135 pub max_parents: Option<usize>,
136
137 /// Only list revisions more recent (or equal) than this timestamp (and stop traversal).
138 pub since: Option<i64>,
139
140 /// Only list revisions older (or equal) than this timestamp (without stopping traversal).
141 pub until: Option<i64>,
142 // author/committer: … // TODO filter on author/committer's name
143 // first_parent: bool, // TODO (once we have access to parent merge order in swh-graph)
144 // grep: … // TODO filter on the content of commit messages
145 // order_by: author/committer/topo // TODO
146}
147
148/// List revision (commit) nodes in reverse chronological order, in the style of
149/// [`git-rev-list`](https://git-scm.com/docs/git-rev-list).
150///
151/// Given a revision node, return a vector of all the revision nodes reachable from it following the
152/// "parent revision" arcs, transitively.
153///
154/// For convenience, a release node can also be passed. If so, it will be peeled (see
155/// [`peel_rel`]) to obtain the underlying revision node and start revision listing from
156/// there.
157///
158/// Node ordering is by *committer* date (not author date), most recent date first. Revisions with
159/// no known committer date are sorted last. Ties are broken by node id, in ascending order.
160///
161/// The revision listing behavior can be customized by passing [`RevListOptions`] to
162/// [`list_revisions_with`].
163pub fn list_revisions<G>(graph: &G, rev: NodeId) -> Result<Vec<NodeId>>
164where
165 G: SwhForwardGraph
166 + SwhGraphWithProperties<Maps: properties::Maps, Timestamps: properties::Timestamps>,
167{
168 list_revisions_with(graph, &[rev], &[], &Default::default())
169}
170
171/// Variant of [`list_revisions`] that allows customizing listing behavior, in particular:
172///
173/// - Multiple start revisions can be specified with `start`.
174/// - Multiple end revisions can be specified with `exclude`.
175/// - Other options, such as revision filtering, can be specified via
176/// [RevListOptions]
177///
178/// Intuitively, this function collects all commits reachable from *all* `start` revisions into a
179/// single set, and then subtracts from it all commits reachable from *all* `exclude` revisions.
180/// Filtering is applied during revision collection; only the [since](RevListOptions::since)
181/// filter limits its reach (see [RevListOptions]). The obtained set is sorted in reverse
182/// chronological order and returned to the caller.
183///
184/// See [`git-rev-list`](https://git-scm.com/docs/git-rev-list) for more information and examples.
185pub fn list_revisions_with<G>(
186 graph: &G,
187 start: &[NodeId],
188 exclude: &[NodeId],
189 opts: &RevListOptions,
190) -> Result<Vec<NodeId>>
191where
192 G: SwhForwardGraph
193 + SwhGraphWithProperties<Maps: properties::Maps, Timestamps: properties::Timestamps>,
194{
195 let props = graph.properties();
196 let rev_constraint: NodeConstraint = "rev".parse().unwrap();
197 let rev_graph = Subgraph::with_node_constraint(graph, rev_constraint);
198
199 // Peel release nodes to revisions; pass revision nodes through
200 let peel_to_rev = |node: NodeId| -> Result<NodeId> {
201 peel_rel(graph, node)?
202 .filter(|&n| props.node_type(n) == NodeType::Revision)
203 .ok_or_else(|| anyhow::anyhow!("Node {node} cannot be peeled to a revision"))
204 };
205
206 let start_revs: Vec<NodeId> = start
207 .iter()
208 .map(|&n| peel_to_rev(n))
209 .collect::<Result<_>>()?;
210 let exclude_revs: Vec<NodeId> = exclude
211 .iter()
212 .map(|&n| peel_to_rev(n))
213 .collect::<Result<_>>()?;
214
215 // Traversal filters: filtered-out commits are neither listed nor traversed through
216 let passes_traversal_filters = |node: NodeId| -> bool {
217 opts.since
218 .is_none_or(|since| props.committer_timestamp(node).is_none_or(|ts| ts >= since))
219 };
220
221 // Count the parents of `node`, stopping at `limit`, i.e., return min(parent count, limit).
222 // Subgraph::outdegree is O(outdegree), so this avoids full counts in the parent-count tests
223 // below, which only need to compare the count against a bound.
224 let count_parents_up_to =
225 |node: NodeId, limit: usize| -> usize { rev_graph.successors(node).take(limit).count() };
226
227 // Output filters: filtered-out commits are not listed, but traversal continues
228 let passes_output_filters = |node: NodeId| -> bool {
229 opts.min_parents
230 .is_none_or(|min| count_parents_up_to(node, min) >= min)
231 && opts
232 .max_parents
233 .is_none_or(|max| count_parents_up_to(node, max.saturating_add(1)) <= max)
234 && opts
235 .until
236 .is_none_or(|until| props.committer_timestamp(node).is_none_or(|ts| ts <= until))
237 };
238
239 // Collect excluded revisions using iter_nodes (plain BFS, no filters), so that the main
240 // traversal below can stop at excluded revisions without visiting their (also excluded)
241 // ancestry.
242 let excluded: AdaptiveNodeSet = {
243 let mut set = AdaptiveNodeSet::new(graph.num_nodes());
244 for node in iter_nodes(&rev_graph, &exclude_revs) {
245 set.insert(node);
246 }
247 set
248 };
249
250 // Collect included revisions via BFS, stopping at excluded revisions (their ancestors must be
251 // excluded too).
252 let mut visited = AdaptiveNodeSet::new(graph.num_nodes());
253 let mut included = Vec::new();
254 let mut queue = VecDeque::new();
255 for &rev in &start_revs {
256 if !visited.contains(rev) && !excluded.contains(rev) {
257 visited.insert(rev);
258 queue.push_back(rev);
259 }
260 }
261 while let Some(node) = queue.pop_front() {
262 if !passes_traversal_filters(node) {
263 continue; // don't list and don't traverse further
264 }
265 if passes_output_filters(node) {
266 included.push(node);
267 }
268 for succ in rev_graph.successors(node) {
269 if !visited.contains(succ) && !excluded.contains(succ) {
270 visited.insert(succ);
271 queue.push_back(succ);
272 }
273 }
274 }
275
276 // Sort by committer timestamp descending (most recent first), breaking ties by node id
277 // ascending to make the result deterministic
278 included.sort_unstable_by_key(|&rev| {
279 (
280 std::cmp::Reverse(props.committer_timestamp(rev).unwrap_or(i64::MIN)),
281 rev,
282 )
283 });
284
285 Ok(included)
286}