Skip to main content

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}