create src

This commit is contained in:
awfixer
2026-03-11 02:04:19 -07:00
commit 52f7a22bf2
2595 changed files with 402870 additions and 0 deletions

2089
src-revision/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

55
src-revision/Cargo.toml Normal file
View File

@@ -0,0 +1,55 @@
lints.workspace = true
[package]
name = "src-revision"
version = "0.42.0"
repository = "https://github.com/GitoxideLabs/gitoxide"
license = "MIT OR Apache-2.0"
description = "A crate of the gitoxide project dealing with finding names for revisions and parsing specifications"
authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"]
edition = "2021"
include = ["src/**/*", "LICENSE-*", "README.md"]
rust-version = "1.82"
[lib]
doctest = false
[features]
default = ["describe", "merge_base"]
## Enable support for the SHA-1 hash by enabling the respective feature in the `src-hash` crate.
sha1 = ["src-hash/sha1"]
## `git describe` functionality
describe = ["dep:src-trace", "dep:src-hashtable"]
## `git merge-base` functionality
merge_base = ["dep:src-trace", "dep:bitflags"]
## Data structures implement `serde::Serialize` and `serde::Deserialize`.
serde = ["dep:serde", "src-hash/serde", "src-object/serde"]
[dependencies]
src-error = { version = "^0.2.0", path = "../src-error" }
src-hash = { version = "^0.22.1", path = "../src-hash" }
src-object = { version = "^0.57.0", path = "../src-object" }
src-date = { version = "^0.15.0", path = "../src-date" }
src-hashtable = { version = "^0.12.0", path = "../src-hashtable", optional = true }
src-revwalk = { version = "^0.28.0", path = "../src-revwalk" }
src-commitgraph = { version = "^0.34.0", path = "../src-commitgraph" }
src-trace = { version = "^0.1.18", path = "../src-trace", optional = true }
bstr = { version = "1.12.0", default-features = false, features = ["std"] }
bitflags = { version = "2", optional = true }
nonempty = "0.12.0"
serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] }
document-features = { version = "0.2.1", optional = true }
[dev-dependencies]
insta = "1.46.3"
src-odb = { path = "../src-odb" }
src-testtools = { path = "../tests/tools" }
permutohedron = "0.2.4"
[package.metadata.docs.rs]
all-features = true
features = ["sha1", "document-features"]

1
src-revision/LICENSE-APACHE Symbolic link
View File

@@ -0,0 +1 @@
../LICENSE-APACHE

1
src-revision/LICENSE-MIT Symbolic link
View File

@@ -0,0 +1 @@
../LICENSE-MIT

11
src-revision/README.md Normal file
View File

@@ -0,0 +1,11 @@
# `src-revision`
### Testing
#### Fuzzing
`cargo fuzz` is used for fuzzing, installable with `cargo install cargo-fuzz`.
Targets can be listed with `cargo fuzz list` and executed via `cargo +nightly fuzz run <target>`,
where `<target>` can be `parse` for example.

7
src-revision/fuzz/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
target
corpus
artifacts
# These usually involve a lot of local CPU time, keep them.
$artifacts
$corpus

View File

@@ -0,0 +1,30 @@
[package]
name = "src-revision-fuzz"
version = "0.0.0"
authors = ["Automatically generated"]
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
src-hash = { path = "../../src-hash" }
src-error = { path = "../../src-error" }
bstr = { version = "1.5.0", default-features = false }
[dependencies.src-revision]
path = ".."
features = ["sha1"]
# Prevent this from interfering with workspaces
[workspace]
members = ["."]
[[bin]]
name = "parse"
path = "fuzz_targets/parse.rs"
test = false
doc = false

View File

@@ -0,0 +1,68 @@
#![no_main]
use gix_revision::spec::parse::{delegate, Delegate};
use libfuzzer_sys::fuzz_target;
use bstr::BStr;
use gix_error::Exn;
fuzz_target!(|data: &[u8]| {
drop(gix_revision::spec::parse(data.into(), &mut Noop));
});
struct Noop;
impl Delegate for Noop {
fn done(&mut self) -> Result<(), Exn> {
Ok(())
}
}
impl delegate::Kind for Noop {
fn kind(&mut self, _kind: gix_revision::spec::Kind) -> Result<(), Exn> {
Ok(())
}
}
impl delegate::Navigate for Noop {
fn traverse(&mut self, _kind: delegate::Traversal) -> Result<(), Exn> {
Ok(())
}
fn peel_until(&mut self, _kind: delegate::PeelTo<'_>) -> Result<(), Exn> {
Ok(())
}
fn find(&mut self, _regex: &BStr, _negated: bool) -> Result<(), Exn> {
Ok(())
}
fn index_lookup(&mut self, _path: &BStr, _stage: u8) -> Result<(), Exn> {
Ok(())
}
}
impl delegate::Revision for Noop {
fn find_ref(&mut self, _name: &BStr) -> Result<(), Exn> {
Ok(())
}
fn disambiguate_prefix(
&mut self,
_prefix: gix_hash::Prefix,
_hint: Option<delegate::PrefixHint<'_>>,
) -> Result<(), Exn> {
Ok(())
}
fn reflog(&mut self, _query: delegate::ReflogLookup) -> Result<(), Exn> {
Ok(())
}
fn nth_checked_out_branch(&mut self, _branch_no: usize) -> Result<(), Exn> {
Ok(())
}
fn sibling_branch(&mut self, _kind: delegate::SiblingBranch) -> Result<(), Exn> {
Ok(())
}
}

View File

@@ -0,0 +1,355 @@
use std::{
borrow::Cow,
fmt::{Display, Formatter},
};
use bstr::BStr;
use gix_hashtable::HashMap;
/// The positive result produced by [describe()][function::describe()].
#[derive(Debug, Clone)]
pub struct Outcome<'name> {
/// The name of the tag or branch that is closest to the commit `id`.
///
/// If `None`, no name was found but it was requested to provide the `id` itself as fallback.
pub name: Option<Cow<'name, BStr>>,
/// The input commit object id that we describe.
pub id: gix_hash::ObjectId,
/// The number of commits that are between the tag or branch with `name` and `id`.
/// These commits are all in the future of the named tag or branch.
pub depth: u32,
/// The mapping between object ids and their names initially provided by the describe call.
pub name_by_oid: HashMap<gix_hash::ObjectId, Cow<'name, BStr>>,
/// The amount of commits we traversed.
pub commits_seen: u32,
}
impl<'a> Outcome<'a> {
/// Turn this outcome into a structure that can display itself in the typical `git describe` format.
pub fn into_format(self, hex_len: usize) -> Format<'a> {
Format {
name: self.name,
id: self.id,
hex_len,
depth: self.depth,
long: false,
dirty_suffix: None,
}
}
}
/// A structure implementing `Display`, producing a `git describe` like string.
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
pub struct Format<'a> {
/// The name of the branch or tag to display, as is.
///
/// If `None`, the `id` will be displayed as a fallback.
pub name: Option<Cow<'a, BStr>>,
/// The `id` of the commit to describe.
pub id: gix_hash::ObjectId,
/// The amount of hex characters to use to display `id`.
pub hex_len: usize,
/// The amount of commits between `name` and `id`, where `id` is in the future of `name`.
pub depth: u32,
/// If true, the long form of the describe string will be produced even if `id` lies directly on `name`,
/// hence has a depth of 0.
pub long: bool,
/// If `Some(suffix)`, it will be appended to the describe string.
/// This should be set if the working tree was determined to be dirty.
pub dirty_suffix: Option<String>,
}
impl Format<'_> {
/// Return true if the `name` is directly associated with `id`, i.e. there are no commits between them.
pub fn is_exact_match(&self) -> bool {
self.depth == 0
}
/// Set this instance to print in long mode, that is if `depth` is 0, it will still print the whole
/// long form even though it's not quite necessary.
///
/// Otherwise, it is allowed to shorten itself.
pub fn long(&mut self, long: bool) -> &mut Self {
self.long = long;
self
}
}
impl Display for Format<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if let Some(name) = self.name.as_deref() {
if !self.long && self.is_exact_match() {
name.fmt(f)?;
} else {
write!(f, "{}-{}-g{}", name, self.depth, self.id.to_hex_with_len(self.hex_len))?;
}
} else {
self.id.to_hex_with_len(self.hex_len).fmt(f)?;
}
if let Some(suffix) = &self.dirty_suffix {
write!(f, "-{suffix}")?;
}
Ok(())
}
}
/// A bit-field which keeps track of which commit is reachable by one of 32 candidates names.
pub type Flags = u32;
const MAX_CANDIDATES: usize = std::mem::size_of::<Flags>() * 8;
/// The options required to call [`describe()`][function::describe()].
#[derive(Clone, Debug)]
pub struct Options<'name> {
/// The candidate names from which to determine the `name` to use for the describe string,
/// as a mapping from a commit id and the name associated with it.
pub name_by_oid: HashMap<gix_hash::ObjectId, Cow<'name, BStr>>,
/// The amount of names we will keep track of. Defaults to the maximum of 32.
///
/// If the number is exceeded, it will be capped at 32 and defaults to 10.
pub max_candidates: usize,
/// If no candidate for naming, always show the abbreviated hash. Default: false.
pub fallback_to_oid: bool,
/// Only follow the first parent during graph traversal. Default: false.
///
/// This may speed up the traversal at the cost of accuracy.
pub first_parent: bool,
}
impl Default for Options<'_> {
fn default() -> Self {
Options {
max_candidates: 10, // the same number as git uses, otherwise we perform worse by default on big repos
name_by_oid: Default::default(),
fallback_to_oid: false,
first_parent: false,
}
}
}
/// The error returned by the [`describe()`](function::describe()) function.
pub type Error = gix_error::Message;
pub(crate) mod function {
use std::{borrow::Cow, cmp::Ordering};
use bstr::BStr;
use gix_error::{message, Exn, ResultExt};
use gix_hash::oid;
use super::{Error, Outcome};
use crate::{
describe::{CommitTime, Flags, Options, MAX_CANDIDATES},
Graph, PriorityQueue,
};
/// Given a `commit` id, traverse the commit `graph` and collect candidate names from the `name_by_oid` mapping to produce
/// an `Outcome`, which converted [`into_format()`](Outcome::into_format()) will produce a typical `git describe` string.
///
/// Note that the `name_by_oid` map is returned in the [`Outcome`], which can be forcefully returned even if there was no matching
/// candidate by setting `fallback_to_oid` to true.
pub fn describe<'name>(
commit: &oid,
graph: &mut Graph<'_, '_, Flags>,
Options {
name_by_oid,
mut max_candidates,
fallback_to_oid,
first_parent,
}: Options<'name>,
) -> Result<Option<Outcome<'name>>, Exn<Error>> {
let _span = gix_trace::coarse!(
"gix_revision::describe()",
commit = %commit,
name_count = name_by_oid.len(),
max_candidates,
first_parent
);
max_candidates = max_candidates.min(MAX_CANDIDATES);
if let Some(name) = name_by_oid.get(commit) {
return Ok(Some(Outcome {
name: name.clone().into(),
id: commit.to_owned(),
depth: 0,
name_by_oid,
commits_seen: 0,
}));
}
if max_candidates == 0 || name_by_oid.is_empty() {
return if fallback_to_oid {
Ok(Some(Outcome {
id: commit.to_owned(),
name: None,
name_by_oid,
depth: 0,
commits_seen: 0,
}))
} else {
Ok(None)
};
}
let mut queue = PriorityQueue::from_iter(Some((u32::MAX, commit.to_owned())));
let mut candidates = Vec::new();
let mut commits_seen = 0;
let mut gave_up_on_commit = None;
graph.clear();
graph.insert(commit.to_owned(), 0u32);
while let Some(commit) = queue.pop_value() {
commits_seen += 1;
let flags = if let Some(name) = name_by_oid.get(&commit) {
if candidates.len() < max_candidates {
let identity_bit = 1 << candidates.len();
candidates.push(Candidate {
name: name.clone(),
commits_in_its_future: commits_seen - 1,
identity_bit,
order: candidates.len(),
});
let flags = graph.get_mut(&commit).expect("inserted");
*flags |= identity_bit;
*flags
} else {
gave_up_on_commit = Some(commit);
break;
}
} else {
graph[&commit]
};
for candidate in candidates
.iter_mut()
.filter(|c| (flags & c.identity_bit) != c.identity_bit)
{
candidate.commits_in_its_future += 1;
}
if queue.is_empty() && !candidates.is_empty() {
// single-trunk history that waits to be replenished.
// Abort early if the best-candidate is in the current commits past.
let mut shortest_depth = Flags::MAX;
let mut best_candidates_at_same_depth = 0_u32;
for candidate in &candidates {
match candidate.commits_in_its_future.cmp(&shortest_depth) {
Ordering::Less => {
shortest_depth = candidate.commits_in_its_future;
best_candidates_at_same_depth = candidate.identity_bit;
}
Ordering::Equal => {
best_candidates_at_same_depth |= candidate.identity_bit;
}
Ordering::Greater => {}
}
}
if (flags & best_candidates_at_same_depth) == best_candidates_at_same_depth {
break;
}
}
parents_by_date_onto_queue_and_track_names(graph, &mut queue, commit, flags, first_parent)?;
}
if candidates.is_empty() {
return if fallback_to_oid {
Ok(Some(Outcome {
id: commit.to_owned(),
name: None,
name_by_oid,
depth: 0,
commits_seen,
}))
} else {
Ok(None)
};
}
candidates.sort_by(|a, b| {
a.commits_in_its_future
.cmp(&b.commits_in_its_future)
.then_with(|| a.order.cmp(&b.order))
});
if let Some(commit_id) = gave_up_on_commit {
queue.insert(u32::MAX, commit_id);
commits_seen -= 1;
}
commits_seen += finish_depth_computation(
queue,
graph,
candidates.first_mut().expect("at least one candidate"),
first_parent,
)?;
Ok(candidates.into_iter().next().map(|c| Outcome {
name: c.name.into(),
id: commit.to_owned(),
depth: c.commits_in_its_future,
name_by_oid,
commits_seen,
}))
}
fn parents_by_date_onto_queue_and_track_names(
graph: &mut Graph<'_, '_, Flags>,
queue: &mut PriorityQueue<CommitTime, gix_hash::ObjectId>,
commit: gix_hash::ObjectId,
commit_flags: Flags,
first_parent: bool,
) -> Result<(), Exn<Error>> {
graph
.insert_parents(
&commit,
&mut |parent_id, parent_commit_date| {
queue.insert(parent_commit_date as u32, parent_id);
commit_flags
},
&mut |_parent_id, flags| *flags |= commit_flags,
first_parent,
)
.or_raise(|| message!("could not insert parents of commit {} into graph", commit.to_hex()))?;
Ok(())
}
fn finish_depth_computation(
mut queue: PriorityQueue<CommitTime, gix_hash::ObjectId>,
graph: &mut Graph<'_, '_, Flags>,
best_candidate: &mut Candidate<'_>,
first_parent: bool,
) -> Result<u32, Exn<Error>> {
let mut commits_seen = 0;
while let Some(commit) = queue.pop_value() {
commits_seen += 1;
let flags = graph[&commit];
if (flags & best_candidate.identity_bit) == best_candidate.identity_bit {
if queue
.iter_unordered()
.all(|id| (graph[id] & best_candidate.identity_bit) == best_candidate.identity_bit)
{
break;
}
} else {
best_candidate.commits_in_its_future += 1;
}
parents_by_date_onto_queue_and_track_names(graph, &mut queue, commit, flags, first_parent)?;
}
Ok(commits_seen)
}
#[derive(Debug)]
struct Candidate<'a> {
name: Cow<'a, BStr>,
commits_in_its_future: Flags,
/// A single bit identifying this candidate uniquely in a bitset
identity_bit: Flags,
/// The order at which we found the candidate, first one has order = 0
order: usize,
}
}
/// The timestamp for the creation date of a commit in seconds since unix epoch.
type CommitTime = u32;

26
src-revision/src/lib.rs Normal file
View File

@@ -0,0 +1,26 @@
//! Interact with git revisions by parsing them from rev-specs and describing them in terms of reference names.
//!
//! ## Feature Flags
#![cfg_attr(
all(doc, feature = "document-features"),
doc = ::document_features::document_features!()
)]
#![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))]
#![deny(missing_docs, rust_2018_idioms, unsafe_code)]
///
#[cfg(feature = "describe")]
pub mod describe;
#[cfg(feature = "describe")]
pub use describe::function::describe;
///
#[allow(clippy::empty_docs)]
#[cfg(feature = "merge_base")]
pub mod merge_base;
#[cfg(feature = "merge_base")]
pub use merge_base::function::merge_base;
///
pub mod spec;
pub use gix_revwalk::{graph, Graph, PriorityQueue};
pub use spec::types::Spec;

View File

@@ -0,0 +1,240 @@
use std::cmp::Ordering;
use gix_hash::ObjectId;
use gix_revwalk::graph;
use super::{Error, Simple};
use crate::{merge_base::Flags, Graph, PriorityQueue};
/// Given a commit at `first` id, traverse the commit `graph` and return all possible merge-base between it and `others`,
/// sorted from best to worst. Returns `None` if there is no merge-base as `first` and `others` don't share history.
/// If `others` is empty, `Some(first)` is returned.
///
/// Note that this function doesn't do any work if `first` is contained in `others`, which is when `first` will be returned
/// as only merge-base right away. This is even the case if some commits of `others` are disjoint.
///
/// Additionally, this function isn't stable and results may differ dependeing on the order in which `first` and `others` are
/// provided due to its special rules.
///
/// If a stable result is needed, use [`merge_base::octopus()`](crate::merge_base::octopus()).
///
/// # Performance
///
/// For repeated calls, be sure to re-use `graph` as its content will be kept and reused for a great speed-up. The contained flags
/// will automatically be cleared.
pub fn merge_base(
first: ObjectId,
others: &[ObjectId],
graph: &mut Graph<'_, '_, graph::Commit<Flags>>,
) -> Result<Option<nonempty::NonEmpty<ObjectId>>, Error> {
let _span = gix_trace::coarse!("gix_revision::merge_base()", ?first, ?others);
if others.is_empty() || others.contains(&first) {
return Ok(Some(nonempty::NonEmpty::new(first)));
}
graph.clear_commit_data(|f| *f = Flags::empty());
let bases = paint_down_to_common(first, others, graph)?;
let bases = remove_redundant(&bases, graph)?;
Ok(nonempty::NonEmpty::from_vec(bases))
}
/// Remove all those commits from `commits` if they are in the history of another commit in `commits`.
/// That way, we return only the topologically most recent commits in `commits`.
fn remove_redundant(
commits: &[(ObjectId, GenThenTime)],
graph: &mut Graph<'_, '_, graph::Commit<Flags>>,
) -> Result<Vec<ObjectId>, Error> {
if commits.is_empty() {
return Ok(Vec::new());
}
graph.clear_commit_data(|f| *f = Flags::empty());
let _span = gix_trace::detail!("gix_revision::remove_redundant()", num_commits = %commits.len());
let sorted_commits = {
let mut v = commits.to_vec();
v.sort_by(|a, b| a.1.cmp(&b.1));
v
};
let mut min_gen_pos = 0;
let mut min_gen = sorted_commits[min_gen_pos].1.generation;
let mut walk_start = Vec::with_capacity(commits.len());
for (id, _) in commits {
let commit = graph.get_mut(id).expect("previously added");
commit.data |= Flags::RESULT;
for parent_id in commit.parents.clone() {
graph
.get_or_insert_full_commit(parent_id, |parent| {
// prevent double-addition
if !parent.data.contains(Flags::STALE) {
parent.data |= Flags::STALE;
walk_start.push((parent_id, GenThenTime::from(&*parent)));
}
})
.map_err(|_| Simple("could not insert parent commit into graph"))?;
}
}
walk_start.sort_by(|a, b| a.0.cmp(&b.0));
// allow walking everything at first.
walk_start
.iter_mut()
.for_each(|(id, _)| graph.get_mut(id).expect("added previously").data.remove(Flags::STALE));
let mut count_still_independent = commits.len();
let mut stack = Vec::new();
while let Some((commit_id, commit_info)) = walk_start.pop().filter(|_| count_still_independent > 1) {
stack.clear();
graph.get_mut(&commit_id).expect("added").data |= Flags::STALE;
stack.push((commit_id, commit_info));
while let Some((commit_id, commit_info)) = stack.last().copied() {
let commit = graph.get_mut(&commit_id).expect("all commits have been added");
let commit_parents = commit.parents.clone();
if commit.data.contains(Flags::RESULT) {
commit.data.remove(Flags::RESULT);
count_still_independent -= 1;
if count_still_independent <= 1 {
break;
}
if *commit_id == *sorted_commits[min_gen_pos].0 {
while min_gen_pos < commits.len() - 1
&& graph
.get(&sorted_commits[min_gen_pos].0)
.expect("already added")
.data
.contains(Flags::STALE)
{
min_gen_pos += 1;
}
min_gen = sorted_commits[min_gen_pos].1.generation;
}
}
if commit_info.generation < min_gen {
stack.pop();
continue;
}
let previous_len = stack.len();
for parent_id in &commit_parents {
if graph
.get_or_insert_full_commit(*parent_id, |parent| {
if !parent.data.contains(Flags::STALE) {
parent.data |= Flags::STALE;
stack.push((*parent_id, GenThenTime::from(&*parent)));
}
})
.map_err(|_| Simple("could not insert parent commit into graph"))?
.is_some()
{
break;
}
}
if previous_len == stack.len() {
stack.pop();
}
}
}
Ok(commits
.iter()
.filter_map(|(id, _info)| {
graph
.get(id)
.filter(|commit| !commit.data.contains(Flags::STALE))
.map(|_| *id)
})
.collect())
}
fn paint_down_to_common(
first: ObjectId,
others: &[ObjectId],
graph: &mut Graph<'_, '_, graph::Commit<Flags>>,
) -> Result<Vec<(ObjectId, GenThenTime)>, Error> {
let mut queue = PriorityQueue::<GenThenTime, ObjectId>::new();
graph
.get_or_insert_full_commit(first, |commit| {
commit.data |= Flags::COMMIT1;
queue.insert(GenThenTime::from(&*commit), first);
})
.map_err(|_| Simple("could not insert commit into graph"))?;
for other in others {
graph
.get_or_insert_full_commit(*other, |commit| {
commit.data |= Flags::COMMIT2;
queue.insert(GenThenTime::from(&*commit), *other);
})
.map_err(|_| Simple("could not insert commit into graph"))?;
}
let mut out = Vec::new();
while queue
.iter_unordered()
.any(|id| graph.get(id).is_some_and(|commit| !commit.data.contains(Flags::STALE)))
{
let (info, commit_id) = queue.pop().expect("we have non-stale");
let commit = graph.get_mut(&commit_id).expect("everything queued is in graph");
let mut flags_without_result = commit.data & (Flags::COMMIT1 | Flags::COMMIT2 | Flags::STALE);
if flags_without_result == (Flags::COMMIT1 | Flags::COMMIT2) {
if !commit.data.contains(Flags::RESULT) {
commit.data |= Flags::RESULT;
out.push((commit_id, info));
}
flags_without_result |= Flags::STALE;
}
for parent_id in commit.parents.clone() {
graph
.get_or_insert_full_commit(parent_id, |parent| {
if (parent.data & flags_without_result) != flags_without_result {
parent.data |= flags_without_result;
queue.insert(GenThenTime::from(&*parent), parent_id);
}
})
.map_err(|_| Simple("could not insert parent commit into graph"))?;
}
}
Ok(out)
}
// TODO(ST): Should this type be used for `describe` as well?
#[derive(Debug, Clone, Copy)]
struct GenThenTime {
/// Note that the special [`GENERATION_NUMBER_INFINITY`](gix_commitgraph::GENERATION_NUMBER_INFINITY) is used to indicate
/// that no commitgraph is available.
generation: gix_revwalk::graph::Generation,
time: gix_date::SecondsSinceUnixEpoch,
}
impl From<&graph::Commit<Flags>> for GenThenTime {
fn from(commit: &graph::Commit<Flags>) -> Self {
GenThenTime {
generation: commit.generation.unwrap_or(gix_commitgraph::GENERATION_NUMBER_INFINITY),
time: commit.commit_time,
}
}
}
impl Eq for GenThenTime {}
impl PartialEq<Self> for GenThenTime {
fn eq(&self, other: &Self) -> bool {
self.cmp(other).is_eq()
}
}
impl PartialOrd<Self> for GenThenTime {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for GenThenTime {
fn cmp(&self, other: &Self) -> Ordering {
self.generation.cmp(&other.generation).then(self.time.cmp(&other.time))
}
}

View File

@@ -0,0 +1,65 @@
bitflags::bitflags! {
/// The flags used in the graph for finding [merge bases](crate::merge_base()).
#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)]
pub struct Flags: u8 {
/// The commit belongs to the graph reachable by the first commit
const COMMIT1 = 1 << 0;
/// The commit belongs to the graph reachable by all other commits.
const COMMIT2 = 1 << 1;
/// Marks the commit as done, it's reachable by both COMMIT1 and COMMIT2.
const STALE = 1 << 2;
/// The commit was already put ontto the results list.
const RESULT = 1 << 3;
}
}
/// The error returned by the [`merge_base()`][function::merge_base()] function.
pub type Error = Simple;
/// A simple error type for merge base operations.
#[derive(Debug)]
pub struct Simple(pub &'static str);
impl std::fmt::Display for Simple {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Simple {}
pub(crate) mod function;
mod octopus {
use gix_hash::ObjectId;
use gix_revwalk::{graph, Graph};
use crate::merge_base::{Error, Flags};
/// Given a commit at `first` id, traverse the commit `graph` and return *the best common ancestor* between it and `others`,
/// sorted from best to worst. Returns `None` if there is no common merge-base as `first` and `others` don't *all* share history.
/// If `others` is empty, `Some(first)` is returned.
///
/// # Performance
///
/// For repeated calls, be sure to re-use `graph` as its content will be kept and reused for a great speed-up. The contained flags
/// will automatically be cleared.
pub fn octopus(
mut first: ObjectId,
others: &[ObjectId],
graph: &mut Graph<'_, '_, graph::Commit<Flags>>,
) -> Result<Option<ObjectId>, Error> {
for other in others {
if let Some(next) =
crate::merge_base(first, std::slice::from_ref(other), graph)?.map(|bases| *bases.first())
{
first = next;
} else {
return Ok(None);
}
}
Ok(Some(first))
}
}
pub use octopus::octopus;

View File

@@ -0,0 +1,109 @@
use crate::Spec;
/// How to interpret a revision specification, or `revspec`.
#[derive(Default, Debug, Copy, Clone, PartialOrd, PartialEq, Ord, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Kind {
/// Include commits reachable from this revision, the default when parsing revision `a` for example, i.e. `a` and its ancestors.
/// Example: `a`.
#[default]
IncludeReachable,
/// Exclude commits reachable from this revision, i.e. `a` and its ancestors. Example: `^a`.
ExcludeReachable,
/// Every commit that is reachable from `b` but not from `a`. Example: `a..b`.
RangeBetween,
/// Every commit reachable through either `a` or `b` but no commit that is reachable by both. Example: `a...b`.
ReachableToMergeBase,
/// Include every commit of all parents of `a`, but not `a` itself. Example: `a^@`.
IncludeReachableFromParents,
/// Exclude every commit of all parents of `a`, but not `a` itself. Example: `a^!`.
ExcludeReachableFromParents,
}
impl Spec {
/// Return the kind of this specification.
pub fn kind(&self) -> Kind {
match self {
Spec::Include(_) => Kind::IncludeReachable,
Spec::Exclude(_) => Kind::ExcludeReachable,
Spec::Range { .. } => Kind::RangeBetween,
Spec::Merge { .. } => Kind::ReachableToMergeBase,
Spec::IncludeOnlyParents { .. } => Kind::IncludeReachableFromParents,
Spec::ExcludeParents { .. } => Kind::ExcludeReachableFromParents,
}
}
}
mod _impls {
use std::fmt::{Display, Formatter};
use crate::Spec;
impl Display for Spec {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Spec::Include(oid) => Display::fmt(oid, f),
Spec::Exclude(oid) => write!(f, "^{oid}"),
Spec::Range { from, to } => write!(f, "{from}..{to}"),
Spec::Merge { theirs, ours } => write!(f, "{theirs}...{ours}"),
Spec::IncludeOnlyParents(from_exclusive) => write!(f, "{from_exclusive}^@"),
Spec::ExcludeParents(oid) => write!(f, "{oid}^!"),
}
}
}
}
pub(crate) mod types {
/// A revision specification without any bindings to a repository, useful for serialization or movement over thread boundaries.
///
/// Note that all [object ids][gix_hash::ObjectId] should be a committish, but don't have to be.
/// Unless the field name contains `_exclusive`, the respective objects are included in the set.
#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Spec {
/// Include commits reachable from this revision, i.e. `a` and its ancestors.
///
/// The equivalent to [crate::spec::Kind::IncludeReachable], but with data.
Include(gix_hash::ObjectId),
/// Exclude commits reachable from this revision, i.e. `a` and its ancestors. Example: `^a`.
///
/// The equivalent to [crate::spec::Kind::ExcludeReachable], but with data.
Exclude(gix_hash::ObjectId),
/// Every commit that is reachable from `from` to `to`, but not any ancestors of `from`. Example: `from..to`.
///
/// The equivalent to [crate::spec::Kind::RangeBetween], but with data.
Range {
/// The starting point of the range, which is included in the set.
from: gix_hash::ObjectId,
/// The end point of the range, which is included in the set.
to: gix_hash::ObjectId,
},
/// Every commit reachable through either `theirs` or `ours`, but no commit that is reachable by both. Example: `theirs...ours`.
///
/// The equivalent to [crate::spec::Kind::ReachableToMergeBase], but with data.
Merge {
/// Their side of the merge, which is included in the set.
theirs: gix_hash::ObjectId,
/// Our side of the merge, which is included in the set.
ours: gix_hash::ObjectId,
},
/// Include every commit of all parents of `a`, but not `a` itself. Example: `a^@`.
///
/// The equivalent to [crate::spec::Kind::IncludeReachableFromParents], but with data.
IncludeOnlyParents(
/// Include only the parents of this object, but not the object itself.
gix_hash::ObjectId,
),
/// Exclude every commit of all parents of `a`, but not `a` itself. Example: `a^!`.
///
/// The equivalent to [crate::spec::Kind::ExcludeReachableFromParents], but with data.
ExcludeParents(
/// Exclude the parents of this object, but not the object itself.
gix_hash::ObjectId,
),
}
}
///
pub mod parse;
pub use parse::function::parse;

View File

@@ -0,0 +1,157 @@
use bstr::BStr;
use gix_error::Exn;
/// Usually the first methods to call when parsing a rev-spec to set an anchoring revision (which is typically a `Commit` object).
/// Methods can be called multiple time to either try input or to parse another rev-spec that is part of a range.
///
/// In one case they will not be called at all, e.g. `@{[-]n}` indicates the current branch (what `HEAD` dereferences to),
/// without ever naming it, and so does `@{upstream}` or `@{<date>}`.
///
/// Note that when dereferencing `HEAD` implicitly, a revision must be set for later navigation.
pub trait Revision {
/// Resolve `name` as reference which might not be a valid reference name. The name may be partial like `main` or full like
/// `refs/heads/main` solely depending on the users input.
/// Symbolic referenced should be followed till their object, but objects **must not yet** be peeled.
fn find_ref(&mut self, name: &BStr) -> Result<(), Exn>;
/// An object prefix to disambiguate, returning `None` if it is ambiguous or wasn't found at all.
///
/// If `hint` is set, it should be used to disambiguate multiple objects with the same prefix.
fn disambiguate_prefix(&mut self, prefix: gix_hash::Prefix, hint: Option<PrefixHint<'_>>) -> Result<(), Exn>;
/// Lookup the reflog of the previously set reference, or dereference `HEAD` to its reference
/// to obtain the ref name (as opposed to `HEAD` itself).
/// If there is no such reflog entry, return `None`.
fn reflog(&mut self, query: ReflogLookup) -> Result<(), Exn>;
/// When looking at `HEAD`, `branch_no` is the non-null checkout in the path, e.g. `1` means the last branch checked out,
/// `2` is the one before that.
/// Return `None` if there is no branch as the checkout history (via the reflog) isn't long enough.
fn nth_checked_out_branch(&mut self, branch_no: usize) -> Result<(), Exn>;
/// Lookup the previously set branch or dereference `HEAD` to its reference to use its name to lookup the sibling branch of `kind`
/// in the configuration (typically in `refs/remotes/…`). The sibling branches are always local tracking branches.
/// Return `None` of no such configuration exists and no sibling could be found, which is also the case for all reference outside
/// of `refs/heads/`.
/// Note that the caller isn't aware if the previously set reference is a branch or not and might call this method even though no reference
/// is known.
fn sibling_branch(&mut self, kind: SiblingBranch) -> Result<(), Exn>;
}
/// Combine one or more specs into a range of multiple.
pub trait Kind {
/// Set the kind of the spec, which happens only once if it happens at all.
/// In case this method isn't called, assume `Single`.
/// Reject a kind by returning `None` to stop the parsing.
///
/// Note that ranges don't necessarily assure that a second specification will be parsed.
/// If `^rev` is given, this method is called with [`spec::Kind::RangeBetween`][crate::spec::Kind::RangeBetween]
/// and no second specification is provided.
///
/// Note that the method can be called even if other invariants are not fulfilled, treat these as errors.
fn kind(&mut self, kind: crate::spec::Kind) -> Result<(), Exn>;
}
/// Once an anchor is set one can adjust it using traversal methods.
pub trait Navigate {
/// Adjust the current revision to traverse the graph according to `kind`.
fn traverse(&mut self, kind: Traversal) -> Result<(), Exn>;
/// Peel the current object until it reached `kind` or `None` if the chain does not contain such object.
fn peel_until(&mut self, kind: PeelTo<'_>) -> Result<(), Exn>;
/// Find the first revision/commit whose message matches the given `regex` (which is never empty).
/// to see how it should be matched.
/// If `negated` is `true`, the first non-match will be a match.
///
/// If no revision is known yet, find the _youngest_ matching commit from _any_ reference, including `HEAD`.
/// Otherwise, only find commits reachable from the currently set revision.
fn find(&mut self, regex: &BStr, negated: bool) -> Result<(), Exn>;
/// Look up the given `path` at the given `stage` in the index returning its blob id,
/// or return `None` if it doesn't exist at this `stage`.
/// Note that this implies no revision is needed and no anchor is set yet.
///
/// * `stage` ranges from 0 to 2, with 0 being the base, 1 being ours, 2 being theirs.
/// * `path` without prefix is relative to the root of the repository, while prefixes like `./` and `../` make it
/// relative to the current working directory.
fn index_lookup(&mut self, path: &BStr, stage: u8) -> Result<(), Exn>;
}
/// A hint to make disambiguation when looking up prefixes possible.
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
pub enum PrefixHint<'a> {
/// The prefix must be a commit.
MustBeCommit,
/// The prefix refers to a commit, anchored to a ref and a revision generation in its future.
DescribeAnchor {
/// The name of the reference, like `v1.2.3` or `main`.
ref_name: &'a BStr,
/// The future generation of the commit we look for, with 0 meaning the commit is referenced by
/// `ref_name` directly.
generation: usize,
},
}
/// A lookup into the reflog of a reference.
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
pub enum ReflogLookup {
/// Lookup by entry, where `0` is the most recent entry, and `1` is the older one behind `0`.
Entry(usize),
/// Lookup the reflog at the given time and find the closest matching entry.
Date(gix_date::Time),
}
/// Define how to traverse the commit graph.
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
pub enum Traversal {
/// Select the given parent commit of the currently selected commit, start at `1` for the first parent.
/// The value will never be `0`.
NthParent(usize),
/// Select the given ancestor of the currently selected commit, start at `1` for the first ancestor.
/// The value will never be `0`.
NthAncestor(usize),
}
/// Define where a tag object should be peeled to.
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
pub enum PeelTo<'a> {
/// An object of the given kind.
ObjectKind(gix_object::Kind),
/// Ensure the object at hand exists and is valid (actually without peeling it),
/// without imposing any restrictions to its type.
/// The object needs to be looked up to assure that it is valid, but it doesn't need to be decoded.
ValidObject,
/// Follow an annotated tag object recursively until an object is found.
RecursiveTagObject,
/// The path to drill into as seen relative to the current tree-ish.
///
/// Note that the path can be relative, and `./` and `../` prefixes are seen as relative to the current
/// working directory.
///
/// The path may be empty, which makes it refer to the tree at the current revision, similar to `^{tree}`.
/// Note that paths like `../` are valid and refer to a tree as seen relative to the current working directory.
Path(&'a BStr),
}
/// The kind of sibling branch to obtain.
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
pub enum SiblingBranch {
/// The upstream branch as configured in `branch.<name>.remote` or `branch.<name>.merge`.
Upstream,
/// The upstream branch to which we would push.
Push,
}
impl SiblingBranch {
/// Parse `input` as branch representation, if possible.
pub fn parse(input: &BStr) -> Option<Self> {
if input.eq_ignore_ascii_case(b"u") || input.eq_ignore_ascii_case(b"upstream") {
SiblingBranch::Upstream.into()
} else if input.eq_ignore_ascii_case(b"push") {
SiblingBranch::Push.into()
} else {
None
}
}
}

View File

@@ -0,0 +1,780 @@
use std::{str::FromStr, time::SystemTime};
use crate::{
spec,
spec::parse::{delegate, delegate::SiblingBranch, Delegate, Error},
};
use bstr::{BStr, BString, ByteSlice, ByteVec};
use gix_error::{ErrorExt, Exn, ResultExt};
/// Parse a git [`revspec`](https://git-scm.com/docs/git-rev-parse#_specifying_revisions) and call `delegate` for each token
/// successfully parsed.
///
/// Note that the `delegate` is expected to maintain enough state to lookup revisions properly.
/// Returns `Ok(())` if all of `input` was consumed, or the error if either the `revspec` syntax was incorrect or
/// the `delegate` failed to perform the request.
pub fn parse(mut input: &BStr, delegate: &mut impl Delegate) -> Result<(), Exn<Error>> {
use delegate::{Kind, Revision};
let mut delegate = InterceptRev::new(delegate);
let mut prev_kind = None;
if let Some(b'^') = input.first() {
input = next(input).1;
let kind = spec::Kind::ExcludeReachable;
delegate
.kind(kind)
.or_raise(|| Error::new(format!("delegate.kind({kind:?}) failed")))?;
prev_kind = kind.into();
}
let mut found_revision;
(input, found_revision) = {
let rest = revision(input, &mut delegate)?;
(rest, rest != input)
};
if delegate.done {
return if input.is_empty() {
Ok(())
} else {
Err(Error::new_with_input("unconsumed input", input).raise())
};
}
if let Some((rest, kind)) = try_range(input) {
if let Some(prev_kind) = prev_kind {
return Err(Error::new(format!(
"cannot set spec kind more than once (was {prev_kind:?}, now {kind:?})"
))
.raise());
}
if !found_revision {
delegate
.find_ref("HEAD".into())
.or_raise(|| Error::new("delegate did not find the HEAD reference"))?;
}
delegate
.kind(kind)
.or_raise(|| Error::new(format!("delegate.kind({kind:?}) failed")))?;
(input, found_revision) = {
let remainder = revision(rest.as_bstr(), &mut delegate)?;
(remainder, remainder != rest)
};
if !found_revision {
delegate
.find_ref("HEAD".into())
.or_raise(|| Error::new("delegate did not find the HEAD reference"))?;
}
}
if input.is_empty() {
delegate
.done()
.or_raise(|| Error::new("No revision was produced after all input was consumed"))
} else {
Err(Error::new_with_input("unconsumed input", input).raise())
}
}
mod intercept {
use crate::spec::parse::{delegate, Delegate};
use bstr::{BStr, BString};
use gix_error::Exn;
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]
pub(crate) enum PrefixHintOwned {
MustBeCommit,
DescribeAnchor { ref_name: BString, generation: usize },
}
impl PrefixHintOwned {
pub fn to_ref(&self) -> delegate::PrefixHint<'_> {
match self {
PrefixHintOwned::MustBeCommit => delegate::PrefixHint::MustBeCommit,
PrefixHintOwned::DescribeAnchor { ref_name, generation } => delegate::PrefixHint::DescribeAnchor {
ref_name: ref_name.as_ref(),
generation: *generation,
},
}
}
}
impl<'a> From<delegate::PrefixHint<'a>> for PrefixHintOwned {
fn from(v: delegate::PrefixHint<'a>) -> Self {
match v {
delegate::PrefixHint::MustBeCommit => PrefixHintOwned::MustBeCommit,
delegate::PrefixHint::DescribeAnchor { generation, ref_name } => PrefixHintOwned::DescribeAnchor {
ref_name: ref_name.to_owned(),
generation,
},
}
}
}
pub(crate) struct InterceptRev<'a, T> {
pub inner: &'a mut T,
pub last_ref: Option<BString>, // TODO: smallvec to save the unnecessary allocation? Can't keep ref due to lifetime constraints in traits
pub last_prefix: Option<(gix_hash::Prefix, Option<PrefixHintOwned>)>,
pub done: bool,
}
impl<'a, T> InterceptRev<'a, T>
where
T: Delegate,
{
pub fn new(delegate: &'a mut T) -> Self {
InterceptRev {
inner: delegate,
last_ref: None,
last_prefix: None,
done: false,
}
}
}
impl<T> Delegate for InterceptRev<'_, T>
where
T: Delegate,
{
fn done(&mut self) -> Result<(), Exn> {
self.done = true;
self.inner.done()
}
}
impl<T> delegate::Revision for InterceptRev<'_, T>
where
T: Delegate,
{
fn find_ref(&mut self, name: &BStr) -> Result<(), Exn> {
self.last_ref = name.to_owned().into();
self.inner.find_ref(name)
}
fn disambiguate_prefix(
&mut self,
prefix: gix_hash::Prefix,
hint: Option<delegate::PrefixHint<'_>>,
) -> Result<(), Exn> {
self.last_prefix = Some((prefix, hint.map(Into::into)));
self.inner.disambiguate_prefix(prefix, hint)
}
fn reflog(&mut self, query: delegate::ReflogLookup) -> Result<(), Exn> {
self.inner.reflog(query)
}
fn nth_checked_out_branch(&mut self, branch_no: usize) -> Result<(), Exn> {
self.inner.nth_checked_out_branch(branch_no)
}
fn sibling_branch(&mut self, kind: delegate::SiblingBranch) -> Result<(), Exn> {
self.inner.sibling_branch(kind)
}
}
impl<T> delegate::Navigate for InterceptRev<'_, T>
where
T: Delegate,
{
fn traverse(&mut self, kind: delegate::Traversal) -> Result<(), Exn> {
self.inner.traverse(kind)
}
fn peel_until(&mut self, kind: delegate::PeelTo<'_>) -> Result<(), Exn> {
self.inner.peel_until(kind)
}
fn find(&mut self, regex: &BStr, negated: bool) -> Result<(), Exn> {
self.inner.find(regex, negated)
}
fn index_lookup(&mut self, path: &BStr, stage: u8) -> Result<(), Exn> {
self.inner.index_lookup(path, stage)
}
}
impl<T> delegate::Kind for InterceptRev<'_, T>
where
T: Delegate,
{
fn kind(&mut self, kind: crate::spec::Kind) -> Result<(), Exn> {
self.inner.kind(kind)
}
}
}
use intercept::InterceptRev;
trait ResultExt2 {
fn or_else_none<F>(self, f: F) -> Option<()>
where
F: FnOnce(Exn);
}
impl ResultExt2 for Result<(), Exn> {
fn or_else_none<F>(self, f: F) -> Option<()>
where
F: FnOnce(Exn),
{
match self {
Ok(()) => Some(()),
Err(err) => {
f(err);
None
}
}
}
}
fn try_set_prefix(
delegate: &mut impl Delegate,
hex_name: &BStr,
hint: Option<delegate::PrefixHint<'_>>,
errors: &mut Vec<Exn>,
) -> Option<()> {
gix_hash::Prefix::from_hex(hex_name.to_str().expect("hexadecimal only"))
.ok()
.and_then(|prefix| {
delegate
.disambiguate_prefix(prefix, hint)
.or_else_none(|err| errors.push(err))
})
}
fn long_describe_prefix(name: &BStr) -> Option<(&BStr, delegate::PrefixHint<'_>)> {
let mut iter = name.rsplit(|b| *b == b'-');
let candidate = iter.by_ref().find_map(|substr| {
if substr.first()? != &b'g' {
return None;
}
let rest = substr.get(1..)?;
rest.iter().all(u8::is_ascii_hexdigit).then(|| rest.as_bstr())
})?;
let candidate = iter.clone().any(|token| !token.is_empty()).then_some(candidate);
let hint = iter
.next()
.and_then(|gen| gen.to_str().ok().and_then(|gen| usize::from_str(gen).ok()))
.and_then(|generation| {
iter.next().map(|token| {
let last_token_len = token.len();
let first_token_ptr = iter.next_back().map_or(token.as_ptr(), <[_]>::as_ptr);
// SAFETY: both pointers are definitely part of the same object
#[allow(unsafe_code)]
let prior_tokens_len: usize = unsafe { token.as_ptr().offset_from(first_token_ptr) }
.try_into()
.expect("positive value");
delegate::PrefixHint::DescribeAnchor {
ref_name: name[..prior_tokens_len + last_token_len].as_bstr(),
generation,
}
})
})
.unwrap_or(delegate::PrefixHint::MustBeCommit);
candidate.map(|c| (c, hint))
}
fn short_describe_prefix(name: &BStr) -> Option<&BStr> {
let mut iter = name.split(|b| *b == b'-');
let candidate = iter
.next()
.and_then(|prefix| prefix.iter().all(u8::is_ascii_hexdigit).then(|| prefix.as_bstr()));
(iter.count() == 1).then_some(candidate).flatten()
}
type InsideParensRestConsumed<'a> = (std::borrow::Cow<'a, BStr>, &'a BStr, usize);
fn parens(input: &[u8]) -> Result<Option<InsideParensRestConsumed<'_>>, Error> {
if input.first() != Some(&b'{') {
return Ok(None);
}
let mut open_braces = 0;
let mut ignore_next = false;
let mut skip_list = Vec::new();
for (idx, b) in input.iter().enumerate() {
match *b {
b'{' => {
if ignore_next {
ignore_next = false;
} else {
open_braces += 1;
}
}
b'}' => {
if ignore_next {
ignore_next = false;
} else {
open_braces -= 1;
}
}
b'\\' => {
skip_list.push(idx);
if ignore_next {
skip_list.pop();
ignore_next = false;
} else {
ignore_next = true;
}
}
_ => {
if ignore_next {
skip_list.pop();
}
ignore_next = false;
}
}
if open_braces == 0 {
let inner: std::borrow::Cow<'_, _> = if skip_list.is_empty() {
input[1..idx].as_bstr().into()
} else {
let mut from = 1;
let mut buf = BString::default();
for next in skip_list.into_iter() {
buf.push_str(&input[from..next]);
from = next + 1;
}
if let Some(rest) = input.get(from..idx) {
buf.push_str(rest);
}
buf.into()
};
return Ok(Some((inner, input[idx + 1..].as_bstr(), idx + 1)));
}
}
Err(Error::new_with_input("unclosed brace pair", input))
}
fn try_parse<T: FromStr + PartialEq + Default>(input: &BStr) -> Result<Option<T>, Error> {
input
.to_str()
.ok()
.and_then(|n| {
n.parse().ok().map(|n| {
if n == T::default() && input[0] == b'-' {
return Err(Error::new_with_input(
"negative zero is invalid - remove the minus sign",
input,
));
}
Ok(n)
})
})
.transpose()
}
fn revision<'a, T>(mut input: &'a BStr, delegate: &mut InterceptRev<'_, T>) -> Result<&'a BStr, Exn<Error>>
where
T: Delegate,
{
use delegate::{Navigate, Revision};
fn consume_all(res: Result<(), Exn>, err: impl FnOnce() -> String) -> Result<&'static BStr, Exn<Error>> {
res.map(|_| "".into()).or_raise(|| Error::new(err()))
}
match input.as_bytes() {
[b':'] => {
return Err(
Error::new("':' must be followed by either slash and regex or path to lookup in HEAD tree").raise(),
)
}
[b':', b'/'] => return Err(Error::new("':/' must be followed by a regular expression").raise()),
[b':', b'/', regex @ ..] => {
let (regex, negated) = parse_regex_prefix(regex.as_bstr())?;
if regex.is_empty() {
return Err(Error::new_with_input("unconsumed input", input).raise());
}
return consume_all(delegate.find(regex, negated), || {
format!("Delegate couldn't find '{regex}' (negated: {negated})")
});
}
[b':', b'0', b':', path @ ..] => {
return consume_all(delegate.index_lookup(path.as_bstr(), 0), || {
format!("Couldn't find index '{path}' stage 0", path = path.as_bstr())
})
}
[b':', b'1', b':', path @ ..] => {
return consume_all(delegate.index_lookup(path.as_bstr(), 1), || {
format!("Couldn't find index '{path}' stage 1", path = path.as_bstr())
})
}
[b':', b'2', b':', path @ ..] => {
return consume_all(delegate.index_lookup(path.as_bstr(), 2), || {
format!("Couldn't find index '{path}' stage 2", path = path.as_bstr())
})
}
[b':', path @ ..] => {
return consume_all(delegate.index_lookup(path.as_bstr(), 0), || {
format!("Couldn't find index '{path}' stage 0 (implicit)", path = path.as_bstr())
})
}
_ => {}
}
let mut sep_pos = None;
let mut consecutive_hex_chars = Some(0);
{
let mut cursor = input;
let mut ofs = 0;
const SEPARATORS: &[u8] = b"~^:.";
while let Some((pos, b)) = cursor.iter().enumerate().find(|(pos, b)| {
if **b == b'@' {
if cursor.len() == 1 {
return true;
}
let next = cursor.get(pos + 1);
let next_next = cursor.get(pos + 2);
if *pos != 0 && (next, next_next) == (Some(&b'.'), Some(&b'.')) {
return false;
}
next == Some(&b'{') || next.is_some_and(|b| SEPARATORS.contains(b))
} else if SEPARATORS.contains(b) {
true
} else {
if let Some(num) = consecutive_hex_chars.as_mut() {
if b.is_ascii_hexdigit() {
*num += 1;
} else {
consecutive_hex_chars = None;
}
}
false
}
}) {
if *b != b'.' || cursor.get(pos + 1) == Some(&b'.') {
sep_pos = Some(ofs + pos);
break;
}
ofs += pos + 1;
cursor = &cursor[pos + 1..];
}
}
let name = &input[..sep_pos.unwrap_or(input.len())].as_bstr();
let mut sep = sep_pos.map(|pos| input[pos]);
let mut has_ref_or_implied_name = name.is_empty();
if name.is_empty() && sep == Some(b'@') && sep_pos.and_then(|pos| input.get(pos + 1)) != Some(&b'{') {
delegate
.find_ref("HEAD".into())
.or_raise(|| Error::new("delegate did not find the HEAD reference"))?;
sep_pos = sep_pos.map(|pos| pos + 1);
sep = match sep_pos.and_then(|pos| input.get(pos).copied()) {
None => return Ok("".into()),
Some(pos) => Some(pos),
};
} else {
let mut errors = Vec::new();
(consecutive_hex_chars.unwrap_or(0) >= gix_hash::Prefix::MIN_HEX_LEN)
.then(|| try_set_prefix(delegate, name, None, &mut errors))
.flatten()
.or_else(|| {
let (prefix, hint) = long_describe_prefix(name)
.map(|(c, h)| (c, Some(h)))
.or_else(|| short_describe_prefix(name).map(|c| (c, None)))?;
try_set_prefix(delegate, prefix, hint, &mut errors)
})
.or_else(|| {
name.is_empty().then_some(()).or_else(|| {
#[allow(clippy::let_unit_value)]
{
let res = delegate.find_ref(name).or_else_none(|err| {
errors.push(err);
})?;
has_ref_or_implied_name = true;
res.into()
}
})
})
.ok_or_else(|| Error::new_with_input("couldn't parse revision", input).raise_all(errors))?;
}
input = {
if let Some(b'@') = sep {
let past_sep = input[sep_pos.map_or(input.len(), |pos| pos + 1)..].as_bstr();
let (nav, rest, _consumed) = parens(past_sep)?.ok_or_else(|| {
Error::new_with_input(
"@ character must be standalone or followed by {<content>}",
&input[sep_pos.unwrap_or(input.len())..],
)
})?;
let nav = nav.as_ref();
if let Some(n) = try_parse::<isize>(nav)? {
if n < 0 {
if name.is_empty() {
delegate.nth_checked_out_branch(n.unsigned_abs()).or_raise(|| {
Error::new_with_input(
format!("delegate.nth_checked_out_branch({n:?}) didn't find a branch"),
nav,
)
})?;
} else {
return Err(Error::new_with_input(
"reference name must be followed by positive numbers in @{n}",
nav,
)
.raise());
}
} else if has_ref_or_implied_name {
let lookup = if n >= 100000000 {
let time = nav
.to_str()
.or_raise(|| Error::new_with_input("could not parse time for reflog lookup", nav))
.and_then(|date| {
gix_date::parse(date, None)
.or_raise(|| Error::new_with_input("could not parse time for reflog lookup", nav))
})?;
delegate::ReflogLookup::Date(time)
} else {
delegate::ReflogLookup::Entry(n.try_into().expect("non-negative isize fits usize"))
};
delegate
.reflog(lookup)
.or_raise(|| Error::new_with_input(format!("delegate.reflog({lookup:?}) failed"), nav))?;
} else {
return Err(Error::new_with_input("reflog entries require a ref name", *name).raise());
}
} else if let Some(kind) = SiblingBranch::parse(nav) {
if has_ref_or_implied_name {
delegate
.sibling_branch(kind)
.or_raise(|| Error::new_with_input(format!("delegate.sibling_branch({kind:?}) failed"), nav))
} else {
Err(Error::new_with_input(
"sibling branches like 'upstream' or 'push' require a branch name with remote configuration",
*name,
)
.raise())
}?;
} else if has_ref_or_implied_name {
let time = nav
.to_str()
.map_err(|_| Error::new_with_input("could not parse time for reflog lookup", nav))
.and_then(|date| {
gix_date::parse(date, Some(SystemTime::now()))
.map_err(|_| Error::new_with_input("could not parse time for reflog lookup", nav))
})?;
let lookup = delegate::ReflogLookup::Date(time);
delegate
.reflog(lookup)
.or_raise(|| Error::new_with_input(format!("delegate.reflog({lookup:?}) failed"), nav))?;
} else {
return Err(Error::new_with_input("reflog entries require a ref name", *name).raise());
}
rest
} else {
if sep_pos == Some(0) && sep == Some(b'~') {
return Err(Error::new("tilde needs to follow an anchor, like @~").raise());
}
input[sep_pos.unwrap_or(input.len())..].as_bstr()
}
};
navigate(input, delegate)
}
fn navigate<'a, T>(input: &'a BStr, delegate: &mut InterceptRev<'_, T>) -> Result<&'a BStr, Exn<Error>>
where
T: Delegate,
{
use delegate::{Kind, Navigate, Revision};
let mut cursor = 0;
let done_msg = "navigation succeeded, but no revision was produced as intermediate step";
while let Some(b) = input.get(cursor) {
cursor += 1;
match *b {
b'~' => {
let (number, consumed) = input
.get(cursor..)
.and_then(|past_sep| try_parse_usize(past_sep.as_bstr()).transpose())
.transpose()?
.unwrap_or((1, 0));
if number != 0 {
let traversal = delegate::Traversal::NthAncestor(number);
delegate.traverse(traversal).or_raise(|| {
Error::new_with_input(format!("delegate.traverse({traversal:?}) failed"), input)
})?;
}
cursor += consumed;
}
b'^' => {
let past_sep = input.get(cursor..);
if let Some((number, negative, consumed)) = past_sep
.and_then(|past_sep| try_parse_isize(past_sep.as_bstr()).transpose())
.transpose()?
{
if negative {
let traversal = delegate::Traversal::NthParent(
number
.checked_mul(-1)
.ok_or_else(|| {
Error::new_with_input("could not parse number", past_sep.expect("present"))
})?
.try_into()
.expect("non-negative"),
);
delegate.traverse(traversal).or_raise(|| {
Error::new_with_input(
"delegate.traverse({traversal:?}) failed",
past_sep.unwrap_or_default(),
)
})?;
let kind = spec::Kind::RangeBetween;
delegate.kind(kind).or_raise(|| {
Error::new_with_input(
format!("delegate.kind({kind:?}) failed"),
past_sep.unwrap_or_default(),
)
})?;
if let Some((prefix, hint)) = delegate.last_prefix.take() {
match &hint {
Some(hint) => delegate.disambiguate_prefix(prefix, hint.to_ref().into()),
None => delegate.disambiguate_prefix(prefix, None),
}
.or_raise(|| {
Error::new_with_input(
format!("delegate.disambiguate_prefix({hint:?}) failed"),
past_sep.unwrap_or_default(),
)
})?;
} else if let Some(name) = delegate.last_ref.take() {
delegate.find_ref(name.as_bstr()).or_raise(|| {
Error::new_with_input(
format!("delegate.find_ref({name}) failed"),
past_sep.unwrap_or_default(),
)
})?;
} else {
return Err(Error::new_with_input("unconsumed input", &input[cursor..]).raise());
}
cursor += consumed;
let rest = input[cursor..].as_bstr();
delegate.done().or_raise(|| Error::new_with_input(done_msg, rest))?;
return Ok(rest);
} else if number == 0 {
delegate.peel_until(delegate::PeelTo::ObjectKind(gix_object::Kind::Commit))
} else {
delegate.traverse(delegate::Traversal::NthParent(
number.try_into().expect("positive number"),
))
}
.or_raise(|| Error::new_with_input("unknown navigation", past_sep.unwrap_or_default()))?;
cursor += consumed;
} else if let Some((kind, _rest, consumed)) =
past_sep.and_then(|past_sep| parens(past_sep).transpose()).transpose()?
{
cursor += consumed;
let target = match kind.as_ref().as_bytes() {
b"commit" => delegate::PeelTo::ObjectKind(gix_object::Kind::Commit),
b"tag" => delegate::PeelTo::ObjectKind(gix_object::Kind::Tag),
b"tree" => delegate::PeelTo::ObjectKind(gix_object::Kind::Tree),
b"blob" => delegate::PeelTo::ObjectKind(gix_object::Kind::Blob),
b"object" => delegate::PeelTo::ValidObject,
b"" => delegate::PeelTo::RecursiveTagObject,
regex if regex.starts_with(b"/") => {
let (regex, negated) = parse_regex_prefix(regex[1..].as_bstr())?;
if !regex.is_empty() {
delegate.find(regex, negated).or_raise(|| {
Error::new(format!("Delegate couldn't find '{regex}' (negated: {negated})"))
})?;
}
continue;
}
invalid => return Err(Error::new_with_input("cannot peel to unknown target", invalid).raise()),
};
delegate.peel_until(target).or_raise(|| {
Error::new_with_input(
format!("delegate.peel_until({target:?}) failed"),
past_sep.unwrap_or_default(),
)
})?;
} else if past_sep.and_then(<[_]>::first) == Some(&b'!') {
let rest = input[cursor + 1..].as_bstr();
let kind = spec::Kind::ExcludeReachableFromParents;
delegate
.kind(kind)
.or_raise(|| Error::new_with_input(format!("delegate.kind({kind:?}) failed"), rest))?;
delegate.done().or_raise(|| Error::new_with_input(done_msg, rest))?;
return Ok(rest);
} else if past_sep.and_then(<[_]>::first) == Some(&b'@') {
let rest = input[cursor + 1..].as_bstr();
let kind = spec::Kind::IncludeReachableFromParents;
delegate
.kind(kind)
.or_raise(|| Error::new_with_input(format!("delegate.kind({kind:?}) failed"), rest))?;
delegate.done().or_raise(|| Error::new_with_input(done_msg, rest))?;
return Ok(rest);
} else {
let parent = delegate::Traversal::NthParent(1);
delegate.traverse(parent).or_raise(|| {
Error::new_with_input(
format!("delegate.parent({parent:?}) failed",),
past_sep.unwrap_or_default(),
)
})?;
}
}
b':' => {
let to = delegate::PeelTo::Path(input[cursor..].as_bstr());
delegate
.peel_until(to)
.or_raise(|| Error::new(format!("delegate.peel_until({to:?}) failed")))?;
return Ok("".into());
}
_ => return Ok(input[cursor - 1..].as_bstr()),
}
}
Ok("".into())
}
fn parse_regex_prefix(regex: &BStr) -> Result<(&BStr, bool), Error> {
Ok(match regex.strip_prefix(b"!") {
Some(regex) if regex.first() == Some(&b'!') => (regex.as_bstr(), false),
Some(regex) if regex.first() == Some(&b'-') => (regex[1..].as_bstr(), true),
Some(_regex) => return Err(Error::new_with_input("need one character after /!, typically -", regex)),
None => (regex, false),
})
}
fn try_parse_usize(input: &BStr) -> Result<Option<(usize, usize)>, Error> {
let mut bytes = input.iter().peekable();
if bytes.peek().filter(|&&&b| b == b'-' || b == b'+').is_some() {
return Err(Error::new_with_input(
"negative or explicitly positive numbers are invalid here",
input,
));
}
let num_digits = bytes.take_while(|b| b.is_ascii_digit()).count();
if num_digits == 0 {
return Ok(None);
}
let input = &input[..num_digits];
let number = try_parse(input)?.ok_or_else(|| Error::new_with_input("could not parse number", input))?;
Ok(Some((number, num_digits)))
}
fn try_parse_isize(input: &BStr) -> Result<Option<(isize, bool, usize)>, Error> {
let mut bytes = input.iter().peekable();
if bytes.peek().filter(|&&&b| b == b'+').is_some() {
return Err(Error::new_with_input(
"explicitly positive numbers are invalid here",
input,
));
}
let negative = bytes.peek() == Some(&&b'-');
let num_digits = bytes.take_while(|b| b.is_ascii_digit() || *b == &b'-').count();
if num_digits == 0 {
return Ok(None);
} else if num_digits == 1 && negative {
return Ok(Some((-1, negative, num_digits)));
}
let input = &input[..num_digits];
let number = try_parse(input)?.ok_or_else(|| Error::new_with_input("could not parse number", input))?;
Ok(Some((number, negative, num_digits)))
}
fn try_range(input: &BStr) -> Option<(&[u8], spec::Kind)> {
input
.strip_prefix(b"...")
.map(|rest| (rest, spec::Kind::ReachableToMergeBase))
.or_else(|| input.strip_prefix(b"..").map(|rest| (rest, spec::Kind::RangeBetween)))
}
fn next(i: &BStr) -> (u8, &BStr) {
let b = i[0];
(b, i[1..].as_bstr())
}

View File

@@ -0,0 +1,21 @@
use gix_error::Exn;
/// The error returned by [`spec::parse()`](crate::spec::parse()).
pub use gix_error::ValidationError as Error;
///
pub mod delegate;
/// A delegate to be informed about parse events, with methods split into categories.
///
/// - **Anchors** - which revision to use as starting point for…
/// - **Navigation** - where to go once from the initial revision
/// - **Range** - to learn if the specification is for a single or multiple references, and how to combine them.
pub trait Delegate: delegate::Revision + delegate::Navigate + delegate::Kind {
/// Called at the end of a successful parsing operation.
/// It can be used as a marker to finalize internal data structures.
///
/// Note that it will not be called if there is unconsumed input.
fn done(&mut self) -> Result<(), Exn>;
}
pub(crate) mod function;

View File

@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -eu -o pipefail
git init
EMPTY_TREE=$(git mktree </dev/null)
function ofs_commit () {
local OFFSET_SECONDS=$1
local COMMIT_NAME=$2
shift 2
PARENTS=
for P; do
PARENTS="${PARENTS}-p $P "
done
GIT_COMMITTER_DATE="$((400403349 + OFFSET_SECONDS)) +0000"
GIT_AUTHOR_DATE=$GIT_COMMITTER_DATE
export GIT_COMMITTER_DATE GIT_AUTHOR_DATE
commit=$(echo $COMMIT_NAME | git commit-tree $EMPTY_TREE ${PARENTS:-})
git update-ref "refs/tags/$COMMIT_NAME" "$commit"
echo $commit
}
function baseline() {
echo "$@"
echo $(git rev-parse "$@")
git merge-base --all "$@" || :
echo
}
# Merge-bases adapted from Git test suite
# No merge base
ofs_commit 0 DA
ofs_commit 100 DB
{
echo "just-one-returns-one-in-code"
echo $(git rev-parse DA)
echo $(git rev-parse DA)
echo
baseline DA DB
baseline DA DA DB
} > 1_disjoint.baseline
# A graph that is purposefully using times that can't be trusted, i.e. the root E
# has a higher time than its future commits, so that it would be preferred
# unless if there was an additional pruning step to deal with this case.
# E---D---C---B---A
# \"-_ \ \
# \ `---------G \
# \ \
# F----------------H
E=$(ofs_commit 5 E)
D=$(ofs_commit 4 D $E)
F=$(ofs_commit 6 F $E)
C=$(ofs_commit 3 C $D)
B=$(ofs_commit 2 B $C)
A=$(ofs_commit 1 A $B)
G=$(ofs_commit 7 G $B $E)
H=$(ofs_commit 8 H $A $F)
{
baseline G H
} > 2_a.baseline
# Permutation testing - let's do it early to avoid too many permutations
commits=$(git log --all --format=%s)
commit_array=($commits)
num_commits=${#commit_array[@]}
for ((i=0; i<num_commits; i++)); do
for ((j=0; j<num_commits; j++)); do
baseline ${commit_array[$i]} ${commit_array[$j]}
done
done > 3_permutations.baseline
# Timestamps cannot be trusted.
#
# Relative
# Structure timestamps
#
# PL PR +4 +4
# / \/ \ / \/ \
# L2 C2 R2 +3 -1 +3
# | | | | | |
# L1 C1 R1 +2 -2 +2
# | | | | | |
# L0 C0 R0 +1 -3 +1
# \ | / \ | /
# S 0
#
# The left and right chains of commits can be of any length and complexity as
# long as all of the timestamps are greater than that of S.
S=$(ofs_commit 0 S)
C0=$(ofs_commit -3 C0 $S)
C1=$(ofs_commit -2 C1 $C0)
C2=$(ofs_commit -1 C2 $C1)
L0=$(ofs_commit 1 L0 $S)
L1=$(ofs_commit 2 L1 $L0)
L2=$(ofs_commit 3 L2 $L1)
R0=$(ofs_commit 1 R0 $S)
R1=$(ofs_commit 2 R1 $R0)
R2=$(ofs_commit 3 R2 $R1)
PL=$(ofs_commit 4 PL $L2 $C2)
PR=$(ofs_commit 4 PR $C2 $R2)
{
baseline PL PR
} > 4_b.baseline
function tick () {
if test -z "${tick+set}"
then
tick=1112911993
else
tick=$(($tick + 60))
fi
GIT_COMMITTER_DATE="$tick -0700"
GIT_AUTHOR_DATE="$tick -0700"
export GIT_COMMITTER_DATE GIT_AUTHOR_DATE
}
tick
function commit() {
local message=${1:?first argument is the commit message}
local date=${2:-}
if [ -n "$date" ]; then
export GIT_COMMITTER_DATE="$date"
else
tick
fi
git commit --allow-empty -m "$message"
git tag "$message"
}
# * C (MMC) * B (MMB) * A (MMA)
# * o * o * o
# * o * o * o
# * o * o * o
# * o | _______/
# | |/
# | * 1 (MM1)
# | _______/
# |/
# * root (MMR)
commit MMR
commit MM1
commit MM-o
commit MM-p
commit MM-q
commit MMA
git checkout MM1
commit MM-r
commit MM-s
commit MM-t
commit MMB
git checkout MMR
commit MM-u
commit MM-v
commit MM-w
commit MM-x
commit MMC
{
baseline MMA MMB MMC
} > 5_c.baseline
merge () {
label="$1"
shift
tick
git merge -m "$label" "$@"
git tag "$label"
}
# JE
# / |
# / |
# / |
# JAA / |
# |\ / |
# | \ | JDD |
# | \ |/ | |
# | JC JD |
# | | /| |
# | |/ | |
# JA | | |
# |\ /| | |
# X JB | X X
# \ \ | / /
# \__\|/___/
# J
commit J
commit JB
git reset --hard J
commit JC
git reset --hard J
commit JTEMP1
merge JA JB
merge JAA JC
git reset --hard J
commit JTEMP2
merge JD JB
merge JDD JC
git reset --hard J
commit JTEMP3
merge JE JC
{
baseline JAA JDD JE
} > 5_c.baseline
git commit-graph write --no-progress --reachable
git repack -adq

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -eu -o pipefail
git init -q
git config merge.ff false
git checkout -q -b main
git commit -q --allow-empty -m c1
git tag at-c1
git commit -q --allow-empty -m c2
git commit -q --allow-empty -m c3
git commit -q --allow-empty -m c4
git checkout -q -b branch1
git commit -q --allow-empty -m b1c1
git tag at-b1c1
git commit -q --allow-empty -m b1c2
git checkout -q main
git commit -q --allow-empty -m c5
git tag at-c5
git merge branch1 -m m1b1
git commit-graph write --no-progress --reachable
git repack -adq
git clone --depth 1 file://$PWD shallow-1-clone
git clone --depth 2 file://$PWD shallow-2-clone

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -eu -o pipefail
git init three-sequential-commits
(cd three-sequential-commits
git commit -m "A" --allow-empty
git commit -m "B" --allow-empty
git commit -m "C" --allow-empty
)
git init three-parallel-commits
(cd three-parallel-commits
git commit -m "BASE" --allow-empty
git branch A
git branch B
git branch C
git checkout A
git commit -m "A" --allow-empty
git checkout B
git commit -m "B" --allow-empty
git checkout C
git commit -m "C" --allow-empty
)
git init three-forked-commits
(cd three-forked-commits
git commit -m "BASE" --allow-empty
git branch A
git checkout -b C
git commit -m "C" --allow-empty
git checkout A
git commit -m "A-1" --allow-empty
git branch B
git commit -m "A-2" --allow-empty
git checkout B
git commit -m "B" --allow-empty
)

View File

@@ -0,0 +1,56 @@
use std::borrow::Cow;
use gix_object::bstr::ByteSlice;
use gix_revision::describe;
use crate::hex_to_id;
#[test]
fn exact_match_with_dirty_and_long() {
let mut format = describe::Outcome {
name: Some(Cow::Borrowed(b"main".as_bstr())),
id: hex_to_id("b920bbb055e1efb9080592a409d3975738b6efb3"),
depth: 0,
name_by_oid: Default::default(),
commits_seen: 0,
}
.into_format(7);
assert!(format.is_exact_match());
assert_eq!(format.to_string(), "main");
assert_eq!(format.long(true).to_string(), "main-0-gb920bbb");
format.dirty_suffix = Some("dirty".into());
assert_eq!(format.long(false).to_string(), "main-dirty");
assert_eq!(format.long(true).to_string(), "main-0-gb920bbb-dirty");
format.dirty_suffix = None;
format.depth = 42;
assert!(!format.is_exact_match());
assert_eq!(format.long(false).to_string(), "main-42-gb920bbb");
format.dirty_suffix = Some("dirty".into());
assert_eq!(format.to_string(), "main-42-gb920bbb-dirty");
assert_eq!(format.long(true).to_string(), "main-42-gb920bbb-dirty");
}
#[test]
fn show_abbrev_hash_if_no_name_is_known() {
let mut format = describe::Outcome {
name: None,
id: hex_to_id("b920bbb055e1efb9080592a409d3975738b6efb3"),
depth: 0,
name_by_oid: Default::default(),
commits_seen: 0,
}
.into_format(7);
assert!(
format.is_exact_match(),
"it reports true as it is only dependent on the depth which plays no role here"
);
assert_eq!(format.long(false).to_string(), "b920bbb");
assert_eq!(format.long(true).to_string(), "b920bbb");
format.dirty_suffix = Some("dirty".into());
assert_eq!(format.long(false).to_string(), "b920bbb-dirty");
assert_eq!(format.long(true).to_string(), "b920bbb-dirty");
}

View File

@@ -0,0 +1,258 @@
use gix_error::Exn;
use gix_object::bstr::ByteSlice;
use gix_revision::{
describe,
describe::{Error, Outcome},
};
use std::{borrow::Cow, path::PathBuf};
use crate::hex_to_id;
mod format;
fn run_test(
transform_odb: impl FnOnce(gix_odb::Handle) -> gix_odb::Handle,
options: impl Fn(gix_hash::ObjectId) -> gix_revision::describe::Options<'static>,
run_assertions: impl Fn(
Result<Option<Outcome<'static>>, Exn<Error>>,
gix_hash::ObjectId,
) -> Result<(), gix_error::Error>,
) -> Result<(), gix_error::Error> {
let store = odb_at(".");
let store = transform_odb(store);
let commit_id = hex_to_id("01ec18a3ebf2855708ad3c9d244306bc1fae3e9b");
for use_commitgraph in [false, true] {
let cache = use_commitgraph
.then(|| gix_commitgraph::Graph::from_info_dir(&store.store_ref().path().join("info")).ok())
.flatten();
let mut graph = gix_revision::Graph::new(&store, cache.as_ref());
run_assertions(
gix_revision::describe(&commit_id, &mut graph, options(commit_id)),
commit_id,
)?;
}
Ok(())
}
#[test]
fn option_none_if_no_tag_found() -> Result<(), gix_error::Error> {
run_test(
std::convert::identity,
|_| Default::default(),
|res, _id| {
assert!(res?.is_none(), "cannot find anything if there's no candidate");
Ok(())
},
)
}
#[test]
fn fallback_if_configured_in_options_but_no_candidate_or_names() -> Result<(), gix_error::Error> {
run_test(
std::convert::identity,
|_| describe::Options {
fallback_to_oid: true,
..Default::default()
},
|res, _id| {
let res = res?.expect("fallback active");
assert!(res.name.is_none(), "no name can be found");
assert_eq!(res.depth, 0, "just a default, not relevant as there is no name");
assert_eq!(
res.commits_seen, 0,
"a traversal is isn't performed as name map is empty, and that's the whole point"
);
assert_eq!(res.into_format(7).to_string(), "01ec18a");
Ok(())
},
)
}
#[test]
fn fallback_if_configured_in_options_and_max_candidates_zero() -> Result<(), gix_error::Error> {
run_test(
std::convert::identity,
|_| describe::Options {
fallback_to_oid: true,
max_candidates: 0,
..Default::default()
},
|res, _id| {
let res = res?.expect("fallback active");
assert!(res.name.is_none(), "no name can be found");
assert_eq!(res.depth, 0, "just a default, not relevant as there is no name");
assert_eq!(res.commits_seen, 0, "we don't do any traversal");
assert_eq!(res.into_format(7).to_string(), "01ec18a");
Ok(())
},
)
}
#[test]
fn not_enough_candidates() -> Result<(), gix_error::Error> {
let name = Cow::Borrowed(b"at-c5".as_bstr());
run_test(
std::convert::identity,
|_| describe::Options {
name_by_oid: vec![
(hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"), name.clone()),
(
hex_to_id("9152eeee2328073cf23dcf8e90c949170b711659"),
b"at-b1c1".as_bstr().into(),
),
]
.into_iter()
.collect(),
max_candidates: 1,
..Default::default()
},
|res, id| {
let res = res?.expect("candidate found");
assert_eq!(res.name, Some(name.clone()), "it finds the youngest/most-recent name");
assert_eq!(res.id, id);
assert_eq!(res.commits_seen, 6, "it has to traverse commits");
assert_eq!(
res.depth, 3,
"it calculates the final number of commits even though it aborted early"
);
Ok(())
},
)
}
#[test]
fn typical_usecases() -> Result<(), gix_error::Error> {
let name = Cow::Borrowed(b"main".as_bstr());
run_test(
std::convert::identity,
|id| describe::Options {
name_by_oid: vec![(id, name.clone())].into_iter().collect(),
max_candidates: 0,
..Default::default()
},
|res, id| {
let res = res?.expect("candidate found");
assert_eq!(
res.name,
Some(name.clone()),
"this is an exact match, and it's found despite max-candidates being 0 (one lookup is always performed)"
);
assert_eq!(res.id, id);
assert_eq!(res.depth, 0);
assert_eq!(res.commits_seen, 0);
Ok(())
},
)?;
let name = Cow::Borrowed(b"at-c5".as_bstr());
run_test(
std::convert::identity,
|_| describe::Options {
name_by_oid: vec![
(hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"), name.clone()),
(
hex_to_id("9152eeee2328073cf23dcf8e90c949170b711659"),
b"at-b1c1".as_bstr().into(),
),
]
.into_iter()
.collect(),
..Default::default()
},
|res, id| {
let res = res?.expect("candidate found");
assert_eq!(
res.name,
Some(name.clone()),
"a match to a tag 1 commit away with 2 commits on the other side of the merge/head"
);
assert_eq!(res.id, id);
assert_eq!(res.depth, 3);
assert_eq!(res.commits_seen, 6);
Ok(())
},
)?;
run_test(
std::convert::identity,
|_| describe::Options {
name_by_oid: vec![
(hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"), name.clone()),
(
hex_to_id("9152eeee2328073cf23dcf8e90c949170b711659"),
b"at-b1c1".as_bstr().into(),
),
]
.into_iter()
.collect(),
first_parent: true,
..Default::default()
},
|res, id| {
let res = res?.expect("candidate found");
assert_eq!(res.name, Some(name.clone()));
assert_eq!(res.id, id);
assert_eq!(res.depth, 1);
assert_eq!(res.commits_seen, 2);
assert_eq!(res.into_format(7).to_string(), "at-c5-1-g01ec18a");
Ok(())
},
)
}
#[test]
fn shallow_yields_no_result_if_provided_refs_are_in_truncated_part_of_history() -> Result<(), gix_error::Error> {
run_test(
|_| odb_at("shallow-1-clone"),
|_| describe::Options {
name_by_oid: vec![(
hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"),
Cow::Borrowed(b"at-c5".as_bstr()),
)]
.into_iter()
.collect(),
first_parent: true,
..Default::default()
},
|res, _id| {
let res = res?;
assert!(
res.is_none(),
"no candidate found on truncated history, and it doesn't crash"
);
Ok(())
},
)
}
#[test]
fn shallow_yields_result_if_refs_are_available() -> Result<(), gix_error::Error> {
let name = Cow::Borrowed(b"at-c5".as_bstr());
run_test(
|_| odb_at("shallow-2-clone"),
|_| describe::Options {
name_by_oid: vec![(hex_to_id("efd9a841189668f1bab5b8ebade9cd0a1b139a37"), name.clone())]
.into_iter()
.collect(),
first_parent: true,
..Default::default()
},
|res, id| {
let res = res?.expect("found candidate");
assert_eq!(res.name, Some(name.clone()));
assert_eq!(res.id, id);
assert_eq!(res.depth, 1);
assert_eq!(res.commits_seen, 2);
assert_eq!(res.into_format(7).to_string(), "at-c5-1-g01ec18a");
Ok(())
},
)
}
fn odb_at(name: &str) -> gix_odb::Handle {
gix_odb::at(fixture_path().join(name).join(".git/objects")).unwrap()
}
fn fixture_path() -> PathBuf {
gix_testtools::scripted_fixture_read_only("make_repo_with_branches.sh").unwrap()
}

View File

@@ -0,0 +1,11 @@
#[cfg(feature = "describe")]
mod describe;
#[cfg(feature = "merge_base")]
mod merge_base;
mod spec;
pub use gix_testtools::Result;
fn hex_to_id(hex: &str) -> gix_hash::ObjectId {
gix_hash::ObjectId::from_hex(hex.as_bytes()).expect("40 bytes hex")
}

View File

@@ -0,0 +1,161 @@
use gix_revision::merge_base;
#[test]
fn validate() -> crate::Result {
let root = gix_testtools::scripted_fixture_read_only("make_merge_base_repos.sh")?;
let mut count = 0;
let odb = gix_odb::at(root.join(".git/objects"))?;
for baseline_path in baseline::expectation_paths(&root)? {
count += 1;
for use_commitgraph in [false, true] {
let cache = use_commitgraph
.then(|| gix_commitgraph::Graph::from_info_dir(&odb.store_ref().path().join("info")).unwrap());
for expected in baseline::parse_expectations(&baseline_path)? {
let mut graph = gix_revision::Graph::new(&odb, cache.as_ref());
let actual = merge_base(expected.first, &expected.others, &mut graph)?;
assert_eq!(
actual,
expected.bases,
"sample {file:?}:{input}",
file = baseline_path.with_extension("").file_name(),
input = expected.plain_input
);
}
let mut graph = gix_revision::Graph::new(&odb, cache.as_ref());
for expected in baseline::parse_expectations(&baseline_path)? {
let actual = merge_base(expected.first, &expected.others, &mut graph)?;
assert_eq!(
actual,
expected.bases,
"sample (reused graph) {file:?}:{input}",
file = baseline_path.with_extension("").file_name(),
input = expected.plain_input
);
}
}
}
assert_ne!(count, 0, "there must be at least one baseline");
Ok(())
}
mod octopus {
use crate::hex_to_id;
#[test]
fn three_sequential_commits() -> crate::Result {
let odb = odb_at("three-sequential-commits")?;
let mut graph = gix_revision::Graph::new(&odb, None);
let first_commit = hex_to_id("e5d0542bd38431f105a8de8e982b3579647feb9f");
let mut heads = vec![
hex_to_id("4fbed377d3eab982d4a465cafaf34b64207da847"),
hex_to_id("8bc2f99c9aacf07568a2bbfe1269f6e543f22d6b"),
first_commit,
];
let mut heap = permutohedron::Heap::new(&mut heads);
while let Some(heads) = heap.next_permutation() {
let actual = gix_revision::merge_base::octopus(*heads.first().unwrap(), &heads[1..], &mut graph)?
.expect("a merge base");
assert_eq!(actual, first_commit);
}
Ok(())
}
#[test]
fn three_parallel_commits() -> crate::Result {
let odb = odb_at("three-parallel-commits")?;
let mut graph = gix_revision::Graph::new(&odb, None);
let base = hex_to_id("3ca3e3dd12585fabbef311d524a5e54678090528");
let mut heads = vec![
hex_to_id("4ce66b336dff547fdeb6cd86e04c617c8d998ff5"),
hex_to_id("6291f6d7da04208dc4ccbbdf9fda98ac9ae67bc0"),
hex_to_id("c507d5413da00c32e5de1ea433030e8e4716bc60"),
];
let mut heap = permutohedron::Heap::new(&mut heads);
while let Some(heads) = heap.next_permutation() {
let actual = gix_revision::merge_base::octopus(*heads.first().unwrap(), &heads[1..], &mut graph)?
.expect("a merge base");
assert_eq!(actual, base);
}
Ok(())
}
#[test]
fn three_forked_commits() -> crate::Result {
let odb = odb_at("three-forked-commits")?;
let mut graph = gix_revision::Graph::new(&odb, None);
let base = hex_to_id("3ca3e3dd12585fabbef311d524a5e54678090528");
let mut heads = vec![
hex_to_id("413d38a3fe7453c68cb7314739d7775f68ab89f5"),
hex_to_id("d4d01a9b6f6fcb23d57cd560229cd9680ec9bd6e"),
hex_to_id("c507d5413da00c32e5de1ea433030e8e4716bc60"),
];
let mut heap = permutohedron::Heap::new(&mut heads);
while let Some(heads) = heap.next_permutation() {
let actual = gix_revision::merge_base::octopus(*heads.first().unwrap(), &heads[1..], &mut graph)?
.expect("a merge base");
assert_eq!(actual, base);
}
Ok(())
}
fn odb_at(name: &str) -> crate::Result<gix_odb::Handle> {
let root = gix_testtools::scripted_fixture_read_only("merge_base_octopus_repos.sh")?;
Ok(gix_odb::at(root.join(name).join(".git/objects"))?)
}
}
mod baseline {
use std::{
ffi::OsStr,
path::{Path, PathBuf},
};
use bstr::ByteSlice;
use gix_hash::ObjectId;
use nonempty::NonEmpty;
/// The expectation as produced by Git itself
#[derive(Debug)]
pub struct Expectation {
pub plain_input: String,
pub first: ObjectId,
pub others: Vec<ObjectId>,
pub bases: Option<NonEmpty<ObjectId>>,
}
pub fn parse_expectations(baseline: &Path) -> std::io::Result<Vec<Expectation>> {
let lines = std::fs::read(baseline)?;
let mut lines = lines.lines();
let mut out = Vec::new();
while let Some(plain_input) = lines.next() {
let plain_input = plain_input.to_str_lossy().into_owned();
let mut input = lines
.next()
.expect("second line is resolved input objects")
.split(|b| *b == b' ');
let first = ObjectId::from_hex(input.next().expect("at least one object")).unwrap();
let others = input.map(|hex_id| ObjectId::from_hex(hex_id).unwrap()).collect();
let bases: Vec<_> = lines
.by_ref()
.take_while(|l| !l.is_empty())
.map(|hex_id| ObjectId::from_hex(hex_id).unwrap())
.collect();
out.push(Expectation {
plain_input,
first,
others,
bases: NonEmpty::from_vec(bases),
});
}
Ok(out)
}
pub fn expectation_paths(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out: Vec<_> = std::fs::read_dir(root)?
.map(Result::unwrap)
.filter_map(|e| (e.path().extension() == Some(OsStr::new("baseline"))).then(|| e.path()))
.collect();
out.sort();
Ok(out)
}
}

View File

@@ -0,0 +1,65 @@
use crate::hex_to_id;
fn oid() -> gix_hash::ObjectId {
hex_to_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
}
fn oid2() -> gix_hash::ObjectId {
hex_to_id("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
}
#[test]
fn include() {
assert_eq!(
gix_revision::Spec::Include(oid()).to_string(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
}
#[test]
fn exclude() {
assert_eq!(
gix_revision::Spec::Exclude(oid()).to_string(),
"^aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
}
#[test]
fn range() {
assert_eq!(
gix_revision::Spec::Range {
from: oid(),
to: oid2()
}
.to_string(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa..bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
);
}
#[test]
fn merge() {
assert_eq!(
gix_revision::Spec::Merge {
theirs: oid(),
ours: oid2()
}
.to_string(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
);
}
#[test]
fn include_parents() {
assert_eq!(
gix_revision::Spec::IncludeOnlyParents(oid()).to_string(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa^@"
);
}
#[test]
fn exclude_parents() {
assert_eq!(
gix_revision::Spec::ExcludeParents(oid()).to_string(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa^!"
);
}

View File

@@ -0,0 +1,2 @@
mod display;
mod parse;

View File

@@ -0,0 +1,226 @@
use crate::spec::parse::{parse, try_parse};
#[test]
fn braces_must_be_closed() {
for unclosed_spec in ["@{something", "@{", "@{..@"] {
let err = try_parse(unclosed_spec).unwrap_err();
assert_eq!(
err.input.as_ref().map(std::convert::AsRef::as_ref),
Some(&unclosed_spec.as_bytes()[1..])
);
}
}
#[test]
#[cfg(target_pointer_width = "64")] // Only works this way on 64-bit systems.
fn fuzzed() {
let rec = parse("@{-9223372036854775808}");
assert_eq!(rec.nth_checked_out_branch, [Some(9223372036854775808), None]);
}
#[test]
fn reflog_by_entry_for_current_branch() {
for (spec, expected_entry) in [("@{0}", 0), ("@{42}", 42), ("@{00100}", 100)] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(
rec.prefix[0], None,
"neither ref nor prefixes are set, straight to navigation"
);
assert_eq!(rec.current_branch_reflog_entry[0], Some(expected_entry.to_string()));
assert_eq!(rec.calls, 1);
}
}
#[test]
fn reflog_by_date_for_current_branch() {
let rec = parse("@{42 +0030}");
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(
rec.prefix[0], None,
"neither ref nor prefixes are set, straight to navigation"
);
assert_eq!(rec.current_branch_reflog_entry[0], Some("42 +0030".to_string()));
assert_eq!(rec.calls, 1);
}
#[test]
fn reflog_by_unix_timestamp_for_current_branch() {
let rec = parse("@{100000000}");
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(
rec.prefix[0], None,
"neither ref nor prefixes are set, straight to navigation"
);
assert_eq!(
rec.current_branch_reflog_entry[0],
Some("100000000 +0000".to_string()),
"This number is the first to count as date"
);
assert_eq!(rec.calls, 1);
let rec = parse("@{99999999}");
assert_eq!(
rec.current_branch_reflog_entry[0],
Some("99999999".to_string()),
"one less is an offset though"
);
}
#[test]
fn reflog_by_date_with_date_parse_failure() {
let err = try_parse("@{foo}").unwrap_err();
insta::assert_snapshot!(err, @"could not parse time for reflog lookup: foo");
}
#[test]
fn reflog_by_date_for_hash_is_invalid() {
for (spec, full_name) in [
("1234@{42 +0030}", "1234"),
("abcd-dirty@{42 +0030}", "abcd-dirty"),
("v1.2.3-0-g1234@{42 +0030}", "v1.2.3-0-g1234"),
] {
let err = try_parse(spec).unwrap_err();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(full_name.as_bytes()));
assert!(err.message.contains("reflog entries require a ref name"));
}
}
#[test]
fn reflog_by_date_for_given_ref_name() {
for (spec, expected_ref) in [
("main@{42 +0030}", "main"),
("refs/heads/other@{42 +0030}", "refs/heads/other"),
("refs/worktree/feature/a@{42 +0030}", "refs/worktree/feature/a"),
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), expected_ref);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.current_branch_reflog_entry[0], Some("42 +0030".to_string()));
assert_eq!(rec.calls, 2, "first the ref, then the reflog entry");
}
}
#[test]
fn reflog_by_entry_for_given_ref_name() {
for (spec, expected_ref, expected_entry) in [
("main@{0}", "main", 0),
("refs/heads/other@{42}", "refs/heads/other", 42),
("refs/worktree/feature/a@{00100}", "refs/worktree/feature/a", 100),
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), expected_ref);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.current_branch_reflog_entry[0], Some(expected_entry.to_string()));
assert_eq!(rec.calls, 2, "first the ref, then the reflog entry");
}
}
#[test]
fn reflog_by_entry_for_hash_is_invalid() {
for (spec, full_name) in [
("1234@{0}", "1234"),
("abcd-dirty@{1}", "abcd-dirty"),
("v1.2.3-0-g1234@{2}", "v1.2.3-0-g1234"),
] {
let err = try_parse(spec).unwrap_err();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(full_name.as_bytes()));
assert!(err.message.contains("reflog entries require a ref name"));
}
}
#[test]
fn sibling_branch_current_branch() {
for (spec, kind_name) in [("@{u}", "Upstream"), ("@{push}", "Push"), ("@{UPSTREAM}", "Upstream")] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], None, "neither ref nor prefix are explicitly set");
assert_eq!(rec.sibling_branch[0].as_deref(), Some(kind_name));
assert_eq!(rec.calls, 1);
}
}
#[test]
fn sibling_branch_for_branch_name() {
for (spec, ref_name, kind_name) in [
("r1@{U}", "r1", "Upstream"),
("refs/heads/main@{Push}", "refs/heads/main", "Push"),
("refs/worktree/private@{UpStreaM}", "refs/worktree/private", "Upstream"),
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), ref_name);
assert_eq!(rec.prefix[0], None, "neither ref nor prefix are explicitly set");
assert_eq!(
rec.sibling_branch[0].as_deref(),
Some(kind_name),
"note that we do not know if something is a branch or not and make the call even if it would not be allowed. Configuration decides"
);
assert_eq!(rec.calls, 2);
}
}
#[test]
fn sibling_branch_for_hash_is_invalid() {
for (spec, full_name) in [
("1234@{u}", "1234"),
("abcd-dirty@{push}", "abcd-dirty"),
("v1.2.3-0-g1234@{upstream}", "v1.2.3-0-g1234"),
] {
let err = try_parse(spec).unwrap_err();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(full_name.as_bytes()));
assert!(err.message.contains("sibling branches"));
}
}
#[test]
fn nth_checked_out_branch_for_refname_is_invalid() {
let err = try_parse("r1@{-1}").unwrap_err();
// its undefined how to handle negative numbers and specified ref names
insta::assert_snapshot!(err, @"reference name must be followed by positive numbers in @{n}: -1");
}
#[test]
fn nth_checked_out_branch() {
for (spec, expected_branch) in [("@{-1}", 1), ("@{-42}", 42), ("@{-00100}", 100)] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(
rec.prefix[0], None,
"neither ref nor prefixes are set, straight to navigation"
);
assert_eq!(rec.nth_checked_out_branch[0], Some(expected_branch));
assert_eq!(rec.calls, 1);
}
}
#[test]
fn numbers_within_braces_cannot_be_negative_zero() {
let err = try_parse("@{-0}").unwrap_err();
// negative zero is not accepted, even though it could easily be defaulted to 0 which is a valid value
insta::assert_snapshot!(err, @"negative zero is invalid - remove the minus sign: -0");
}
#[test]
fn numbers_within_braces_can_be_positive_zero() {
assert_eq!(
parse("@{+0}"),
parse("@{0}"),
"+ prefixes are allowed though and the same as without it"
);
}

View File

@@ -0,0 +1,140 @@
use crate::spec::parse::{parse, try_parse};
#[test]
fn regex_parsing_ignores_ranges_as_opposed_to_git() {
for spec in [":/a..b", ":/a...b"] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(
rec.patterns,
vec![(spec[2..].into(), false)],
"git parses ranges but I think it's merely coincidental rather than intended, not doing so allows to use '.' more liberally"
);
}
}
#[test]
fn index_lookups_ignores_ranges_as_opposed_to_git() {
for spec in [":a..b", ":a...b"] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(
rec.index_lookups,
vec![(spec[1..].into(), 0)],
"git parses ranges but it's never useful as these specs only ever produce blob ids"
);
}
}
#[test]
fn various_forms_of_regex() {
for (spec, (regex, negated)) in [
(":/simple", ("simple", false)),
(":/!-negated", ("negated", true)),
(":/^from start", ("^from start", false)),
(":/!!leading exclamation mark", ("!leading exclamation mark", false)),
(":/with count{1}", ("with count{1}", false)),
(
":/all-consuming makes navigation impossible^5~10",
("all-consuming makes navigation impossible^5~10", false),
),
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.patterns, vec![(regex.into(), negated)]);
assert_eq!(rec.calls, 1);
}
}
#[test]
fn regex_do_not_get_any_backslash_processing() {
for (spec, regex) in [(r#":/{"#, "{"), (r":/\{\}", r"\{\}"), (r":/\\\\\}", r"\\\\\}")] {
let rec = parse(spec);
assert_eq!(rec.patterns, vec![(regex.into(), false)]);
assert_eq!(rec.calls, 1);
}
}
#[test]
fn various_valid_index_lookups_by_path() {
for spec in [
":path",
":dir/path",
":./relative-to.cwd",
":../relative-to-cwd-too",
":navigation/is/ignored~10^^^",
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.index_lookups, vec![(spec[1..].into(), 0)]);
assert_eq!(rec.peel_to, vec![], "peeling only works for anchors");
assert_eq!(rec.calls, 1);
}
}
#[test]
fn various_valid_index_lookups_by_path_and_stage() {
for (spec, path, stage) in [
(":0:path", "path", 0),
(":1:dir/path", "dir/path", 1),
(":2:dir/path@{part-of-path}", "dir/path@{part-of-path}", 2),
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.index_lookups, vec![(path.into(), stage)]);
assert_eq!(rec.peel_to, vec![], "peeling only works for anchors");
assert_eq!(rec.calls, 1);
}
}
#[test]
fn empty_top_level_regex_are_invalid() {
let err = try_parse(":/").unwrap_err();
// git also can't do it, finds nothing instead. It could be the youngest commit in theory, but isn't
insta::assert_snapshot!(err, @"':/' must be followed by a regular expression");
}
#[test]
fn regex_with_empty_exclamation_mark_prefix_is_invalid() {
let err = try_parse(r#":/!hello"#).unwrap_err();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(b"!hello".as_ref()));
insta::assert_snapshot!(err, @"need one character after /!, typically -: !hello");
}
#[test]
fn needs_suffix() {
let err = try_parse(":").unwrap_err();
// git also can't do it, finds nothing instead. It could be the youngest commit in theory, but isn't
insta::assert_snapshot!(err, @"':' must be followed by either slash and regex or path to lookup in HEAD tree");
}
#[test]
fn invalid_index_stage_is_part_of_path() {
for spec in [":4:file", ":5:file", ":01:file", ":10:file"] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.patterns, vec![]);
assert_eq!(
rec.index_lookups,
vec![(spec[1..].into(), 0)],
"these count as stage 0 lookups"
);
assert_eq!(rec.peel_to, vec![]);
assert_eq!(rec.calls, 1);
}
}

View File

@@ -0,0 +1,132 @@
use crate::spec::parse::{parse, try_parse_opts, Options, PrefixHintOwned};
fn anchor_hint() -> Option<PrefixHintOwned> {
Some(PrefixHintOwned::DescribeAnchor {
ref_name: "cargo-smart-release".into(),
generation: 679,
})
}
#[test]
fn full_format_parses_hash_portion_as_prefix() {
let rec = parse("cargo-smart-release-679-g3bee7fb");
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None, "references are not resolved in describe output");
assert_eq!(rec.prefix[0], Some(gix_hash::Prefix::from_hex("3bee7fb").unwrap()));
assert_eq!(rec.prefix_hint[0], anchor_hint());
assert_eq!(rec.calls, 1);
let rec = parse("v1.0-0-g3bee7fb");
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None, "references are not resolved in describe output");
assert_eq!(rec.prefix[0], Some(gix_hash::Prefix::from_hex("3bee7fb").unwrap()));
assert_eq!(
rec.prefix_hint[0],
Some(PrefixHintOwned::DescribeAnchor {
ref_name: "v1.0".into(),
generation: 0,
})
);
assert_eq!(rec.calls, 1);
}
#[test]
fn full_format_lookalikes_fallback_to_ref() {
let spec = "cargo-smart-release-679-g3bee7fb";
let rec = try_parse_opts(
spec,
Options {
reject_prefix: true,
..Default::default()
},
)
.unwrap();
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), spec);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.prefix_hint[0], None);
assert_eq!(rec.calls, 2, "call prefix, then call ref");
}
#[test]
fn any_hash_without_suffix_and_prefix_g_is_assumed_to_be_describe_output() {
let spec = "foo--bar-gabcdef1";
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(
rec.prefix[0],
Some(gix_hash::Prefix::from_hex("abcdef1").unwrap()),
"git does not parse very precisely here"
);
assert_eq!(rec.prefix_hint[0], Some(PrefixHintOwned::MustBeCommit));
assert_eq!(rec.calls, 1);
for invalid_describe in ["-gabcdef1", "gabcdef1"] {
let rec = parse(invalid_describe);
assert!(rec.kind.is_none());
assert_eq!(
rec.get_ref(0),
invalid_describe,
"we don't consider this a prefix from a describe block"
);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.prefix_hint[0], None);
assert_eq!(rec.calls, 1);
}
}
#[test]
fn full_format_with_dirty_suffix_is_recognized() {
let rec = parse("cargo-smart-release-679-g3bee7fb-dirty");
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None, "git does not see this as prefix, we do");
assert_eq!(rec.prefix[0], Some(gix_hash::Prefix::from_hex("3bee7fb").unwrap()));
assert_eq!(rec.prefix_hint[0], anchor_hint());
assert_eq!(rec.calls, 1);
}
#[test]
fn partial_format_with_dirty_suffix_is_recognized() {
let spec = "abcdef1-dirty";
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.find_ref[0], None);
assert_eq!(
rec.prefix[0],
Some(gix_hash::Prefix::from_hex("abcdef1").unwrap()),
"git does not see this as prefix anymore, we do"
);
assert_eq!(
rec.prefix_hint[0], None,
"This leaves room for improvement as we could assume that -dirty belongs to a revision, so this could be PrefixHint::MustBeCommit"
);
assert_eq!(rec.calls, 1);
}
#[test]
fn partial_format_lookalikes_are_never_considered() {
let spec = "abcdef1-dirty-laundry";
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), spec);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.calls, 1, "we don't even try the prefix");
}
#[test]
fn partial_format_with_dirty_suffix_lookalikes_are_treated_as_refs() {
let spec = "abcdef1-dirty";
let rec = try_parse_opts(
spec,
Options {
reject_prefix: true,
..Default::default()
},
)
.unwrap();
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), spec);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.calls, 2);
}

View File

@@ -0,0 +1,66 @@
use crate::spec::parse::{parse, try_parse_opts, Options};
#[test]
fn short_hex_literals_are_considered_prefixes() {
let rec = parse("abCD");
assert!(rec.kind.is_none());
assert_eq!(
rec.find_ref[0], None,
"references are not resolved if prefix lookups succeed"
);
assert_eq!(rec.prefix[0], Some(gix_hash::Prefix::from_hex("abcd").unwrap()));
assert_eq!(rec.prefix_hint[0], None);
assert_eq!(rec.calls, 1);
let rec = parse("gabcd123");
assert!(rec.kind.is_none());
assert_eq!(
rec.get_ref(0),
"gabcd123",
"ref lookups are performed if it doesn't look like a hex sha"
);
assert_eq!(
rec.prefix[0], None,
"prefix lookups are not attempted at all (and they are impossible even)"
);
assert_eq!(rec.prefix_hint[0], None);
assert_eq!(rec.calls, 1);
}
#[test]
fn unresolvable_hex_literals_are_resolved_as_refs() {
let rec = try_parse_opts(
"abCD",
Options {
reject_prefix: true,
..Default::default()
},
)
.unwrap();
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "abCD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.prefix_hint[0], None);
assert_eq!(rec.calls, 2);
}
#[test]
fn hex_literals_that_are_too_long_are_resolved_as_refs() {
let spec = "abcd123456789abcd123456789abcd123456789abcd123456789abcd123456789abcd123456789abcd123456789";
let rec = try_parse_opts(
spec,
Options {
reject_prefix: true,
..Default::default()
},
)
.unwrap();
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), spec);
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.prefix_hint[0], None);
assert_eq!(
rec.calls, 1,
"we can't create a prefix from it, hence only ref resolution is attempted"
);
}

View File

@@ -0,0 +1,5 @@
mod at_symbol;
mod colon_symbol;
mod describe;
mod hash;
mod refnames;

View File

@@ -0,0 +1,68 @@
use crate::spec::parse::{parse, try_parse};
#[test]
fn at_by_itself_is_shortcut_for_head() {
let rec = parse("@");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
}
#[test]
fn at_is_allowed() {
for name in ["a@b", "@branch", "branch@", "@@", "@inner@"] {
let rec = parse(name);
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), name);
assert_eq!(rec.find_ref[1], None);
}
}
#[test]
fn at_in_ranges_is_allowed() {
let input = "@@@..";
let rec = parse(input);
assert_eq!(rec.kind, Some(gix_revision::spec::Kind::RangeBetween));
assert_eq!(rec.get_ref(0), "@@@");
assert_eq!(rec.get_ref(1), "HEAD");
let input = "@@...@@";
let rec = parse(input);
assert_eq!(rec.kind, Some(gix_revision::spec::Kind::ReachableToMergeBase));
assert_eq!(rec.get_ref(0), "@@");
assert_eq!(rec.get_ref(1), "@@");
}
#[test]
fn strange_revspecs_do_not_panic() {
let err = try_parse(".@.").unwrap_err();
insta::assert_snapshot!(err, @"@ character must be standalone or followed by {<content>}: @.");
}
#[test]
fn refname_head() {
let rec = parse("HEAD");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
}
#[test]
fn refname_tag() {
let spec = "v1.2.3.4-beta.1";
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), spec);
}
#[test]
fn refname_with_head_prefix() {
let rec = parse("HEADfake");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEADfake");
}
#[test]
fn full_head_ref_name() {
let rec = parse("refs/heads/main");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "refs/heads/main");
}

View File

@@ -0,0 +1,425 @@
use crate::spec::parse::{try_parse, try_parse_opts, Options};
#[test]
fn cannot_declare_ranges_multiple_times() {
for invalid_spec in ["^HEAD..", "^HEAD..."] {
let err = try_parse(invalid_spec).unwrap_err().into_inner();
assert!(err.message.contains("cannot set spec kind more than once"));
}
}
#[test]
fn delegate_can_refuse_spec_kinds() {
let err = try_parse_opts(
"^HEAD",
Options {
reject_kind: true,
..Default::default()
},
)
.unwrap_err()
.into_inner();
// Delegates can refuse spec kind changes to abort parsing early in case they want single-specs only
insta::assert_snapshot!(err, @"delegate.kind(ExcludeReachable) failed");
}
mod include_parents {
use gix_revision::spec;
use crate::spec::parse::{kind::prefix, parse, try_parse, Call};
#[test]
fn trailing_caret_at_symbol() {
let rec = parse("HEAD^@");
assert_eq!(rec.kind.unwrap(), spec::Kind::IncludeReachableFromParents);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Kind]);
assert!(rec.done);
let rec = parse("abcd^@");
assert_eq!(rec.kind.unwrap(), spec::Kind::IncludeReachableFromParents);
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.order, [Call::DisambiguatePrefix, Call::Kind]);
assert!(rec.done);
let rec = parse("r1^@");
assert_eq!(rec.kind.unwrap(), spec::Kind::IncludeReachableFromParents);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Kind]);
assert!(rec.done);
}
#[test]
fn trailing_caret_exclamation_mark_must_end_the_input() {
let err = try_parse("r1^@~1").unwrap_err().into_inner();
assert!(err.message.contains("unconsumed input"));
}
}
mod exclude_parents {
use gix_revision::spec;
use crate::spec::parse::{kind::prefix, parse, try_parse, Call};
#[test]
fn freestanding() {
let rec = parse("^!");
assert_eq!(
rec.kind,
Some(gix_revision::spec::Kind::ExcludeReachable),
"the delegate has to be able to deal with this"
);
}
#[test]
fn trailing_caret_exclamation_mark() {
let rec = parse("HEAD^!");
assert_eq!(rec.kind.unwrap(), spec::Kind::ExcludeReachableFromParents);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Kind]);
assert!(rec.done);
let rec = parse("abcd^!");
assert_eq!(rec.kind.unwrap(), spec::Kind::ExcludeReachableFromParents);
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.order, [Call::DisambiguatePrefix, Call::Kind]);
assert!(rec.done);
let rec = parse("r1^!");
assert_eq!(rec.kind.unwrap(), spec::Kind::ExcludeReachableFromParents);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Kind]);
assert!(rec.done);
}
#[test]
fn trailing_caret_exclamation_mark_must_end_the_input() {
let err = try_parse("r1^!~1").unwrap_err().into_inner();
assert!(err.message.contains("unconsumed input"));
}
}
mod exclusive {
use gix_revision::spec;
use crate::spec::parse::{kind::prefix, parse};
#[test]
fn freestanding() {
let rec = parse("^");
assert_eq!(
rec.kind,
Some(gix_revision::spec::Kind::ExcludeReachable),
"the delegate has to be able to deal with this"
);
}
#[test]
fn leading_caret() {
let rec = parse("^HEAD");
assert_eq!(rec.kind.unwrap(), spec::Kind::ExcludeReachable);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.calls, 2);
let rec = parse("^abcd");
assert_eq!(rec.kind.unwrap(), spec::Kind::ExcludeReachable);
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.calls, 2);
let rec = parse("^r1");
assert_eq!(rec.kind.unwrap(), spec::Kind::ExcludeReachable);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.calls, 2);
let rec = parse("^hello-0-gabcd-dirty");
assert_eq!(rec.kind.unwrap(), spec::Kind::ExcludeReachable);
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.calls, 2);
}
}
mod range {
use gix_revision::{spec, spec::parse::delegate::Traversal};
use crate::spec::parse::{kind::prefix, parse, try_parse, Call};
#[test]
fn minus_with_n_omitted() {
let rec = parse("r1^-");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.traversal, [Traversal::NthParent(1)], "default is 1");
assert_eq!(rec.get_ref(1), "r1");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Traverse, Call::Kind, Call::FindRef]);
assert!(rec.done);
let rec = parse("@^-");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.traversal, [Traversal::NthParent(1)], "default is 1");
assert_eq!(rec.get_ref(1), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Traverse, Call::Kind, Call::FindRef]);
assert!(rec.done);
let rec = parse("abcd^-");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.traversal, [Traversal::NthParent(1)], "default is 1");
assert_eq!(rec.prefix[1], prefix("abcd").into());
assert_eq!(
rec.order,
[
Call::DisambiguatePrefix,
Call::Traverse,
Call::Kind,
Call::DisambiguatePrefix
]
);
assert!(rec.done);
}
#[test]
fn minus_with_n() {
let rec = parse("r1^-42");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.traversal, [Traversal::NthParent(42)]);
assert_eq!(rec.get_ref(1), "r1");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Traverse, Call::Kind, Call::FindRef]);
assert!(rec.done);
let rec = parse("@^-42");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.traversal, [Traversal::NthParent(42)]);
assert_eq!(rec.get_ref(1), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Traverse, Call::Kind, Call::FindRef]);
assert!(rec.done);
let rec = parse("abcd^-42");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.traversal, [Traversal::NthParent(42)]);
assert_eq!(rec.prefix[1], prefix("abcd").into());
assert_eq!(
rec.order,
[
Call::DisambiguatePrefix,
Call::Traverse,
Call::Kind,
Call::DisambiguatePrefix
]
);
assert!(rec.done);
}
#[test]
fn minus_with_n_omitted_has_to_end_there() {
let err = try_parse("r1^-^").unwrap_err().into_inner();
assert!(err.message.contains("unconsumed input"));
}
#[test]
fn minus_with_n_has_to_end_there() {
let err = try_parse("r1^-42^").unwrap_err().into_inner();
assert!(err.message.contains("unconsumed input"));
}
#[test]
fn minus_with_n_has_to_end_there_and_handle_range_suffix() {
let err = try_parse("r1^-42..").unwrap_err().into_inner();
assert!(err.message.contains("unconsumed input"));
}
#[test]
fn minus_with_n_omitted_has_to_end_there_and_handle_range_suffix() {
let err = try_parse("r1^-..").unwrap_err().into_inner();
assert!(err.message.contains("unconsumed input"));
}
#[test]
fn freestanding_dot_dot() {
let rec = parse("..");
assert_eq!(
rec.kind,
Some(gix_revision::spec::Kind::RangeBetween),
"the delegate has to be able to deal with this"
);
}
#[test]
fn trailing_dot_dot() {
let rec = parse("r1..");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.get_ref(1), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Kind, Call::FindRef]);
}
#[test]
fn leading_dot_dot() {
let rec = parse("..r2");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.get_ref(1), "r2");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.order, [Call::FindRef, Call::Kind, Call::FindRef]);
}
#[test]
fn middle_dot_dot() {
let rec = parse("@..r2");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.get_ref(1), "r2");
assert_eq!(rec.calls, 3);
let rec = parse("r1..r2");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.get_ref(1), "r2");
assert_eq!(rec.calls, 3);
let rec = parse("abcd..1234");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.prefix[1], prefix("1234").into());
assert_eq!(rec.calls, 3);
let rec = parse("r1..abcd");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.calls, 3);
let rec = parse("abcd-dirty..v1.2-42-g1234");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.prefix[1], prefix("1234").into());
assert_eq!(rec.calls, 3);
let rec = parse("v1.2-42-g1234..abcd-dirty");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], prefix("1234").into());
assert_eq!(rec.prefix[1], prefix("abcd").into());
assert_eq!(rec.calls, 3);
let rec = parse("v1.2.4@{1}~~^10..r1@{2}~10^2");
assert_eq!(rec.kind.unwrap(), spec::Kind::RangeBetween);
assert_eq!(rec.get_ref(0), "v1.2.4");
assert_eq!(rec.get_ref(1), "r1");
assert_eq!(&rec.prefix, &[None, None]);
assert_eq!(
rec.traversal,
[
Traversal::NthAncestor(1),
Traversal::NthAncestor(1),
Traversal::NthParent(10),
Traversal::NthAncestor(10),
Traversal::NthParent(2)
]
);
assert_eq!(rec.calls, 10);
assert!(rec.done);
}
}
mod mergebase {
use gix_revision::{spec, spec::parse::delegate::Traversal};
use crate::spec::parse::{kind::prefix, parse};
#[test]
fn freestanding_dot_dot_dot() {
let rec = parse("...");
assert_eq!(
rec.kind,
Some(gix_revision::spec::Kind::ReachableToMergeBase),
"the delegate has to be able to deal with this"
);
}
#[test]
fn trailing_dot_dot_dot() {
let rec = parse("HEAD...");
assert_eq!(rec.kind.unwrap(), spec::Kind::ReachableToMergeBase);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.calls, 3);
}
#[test]
fn leading_dot_dot_dot() {
let rec = parse("...r2");
assert_eq!(rec.kind.unwrap(), spec::Kind::ReachableToMergeBase);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.get_ref(1), "r2");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.calls, 3);
}
#[test]
fn middle_dot_dot_dot() {
let rec = parse("HEAD...@");
assert_eq!(rec.kind.unwrap(), spec::Kind::ReachableToMergeBase);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.get_ref(1), "HEAD");
assert_eq!(rec.calls, 3);
let rec = parse("@...HEAD");
assert_eq!(rec.kind.unwrap(), spec::Kind::ReachableToMergeBase);
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.get_ref(1), "HEAD");
assert_eq!(rec.calls, 3);
let rec = parse("r1...abcd");
assert_eq!(rec.kind.unwrap(), spec::Kind::ReachableToMergeBase);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.prefix[0], prefix("abcd").into());
assert_eq!(rec.calls, 3);
let rec = parse("v1.2.3-beta.1-42-g1234-dirty...abcd-dirty");
assert_eq!(rec.kind.unwrap(), spec::Kind::ReachableToMergeBase);
assert_eq!(rec.find_ref[0], None);
assert_eq!(rec.prefix[0], prefix("1234").into());
assert_eq!(rec.prefix[1], prefix("abcd").into());
assert_eq!(rec.calls, 3);
let rec = parse("r1@{1}~~^10...@{2}~10^2");
assert_eq!(rec.kind.unwrap(), spec::Kind::ReachableToMergeBase);
assert_eq!(rec.get_ref(0), "r1");
assert_eq!(rec.find_ref[1], None, "HEAD is implied");
assert_eq!(&rec.prefix, &[None, None]);
assert_eq!(
rec.traversal,
[
Traversal::NthAncestor(1),
Traversal::NthAncestor(1),
Traversal::NthParent(10),
Traversal::NthAncestor(10),
Traversal::NthParent(2)
]
);
assert_eq!(rec.calls, 9);
assert!(rec.done);
}
}
fn prefix(hex: &str) -> gix_hash::Prefix {
gix_hash::Prefix::from_hex(hex).unwrap()
}

View File

@@ -0,0 +1,273 @@
use gix_error::{bail, message, ErrorExt, Exn};
use gix_object::bstr::{BStr, BString};
use gix_revision::{
spec,
spec::parse::{delegate, Delegate},
};
#[derive(Default, Debug, Eq, PartialEq, Ord, PartialOrd)]
struct Options {
reject_kind: bool,
reject_prefix: bool,
no_internal_assertions: bool,
}
#[derive(Default, Debug, Eq, PartialEq, Ord, PartialOrd)]
struct Recorder {
// anchors
find_ref: [Option<BString>; 2],
prefix: [Option<gix_hash::Prefix>; 2],
prefix_hint: [Option<PrefixHintOwned>; 2],
current_branch_reflog_entry: [Option<String>; 2],
nth_checked_out_branch: [Option<usize>; 2],
sibling_branch: [Option<String>; 2],
index_lookups: Vec<(BString, u8)>,
// navigation
traversal: Vec<delegate::Traversal>,
peel_to: Vec<PeelToOwned>,
patterns: Vec<(BString, bool)>,
// range
kind: Option<spec::Kind>,
order: Vec<Call>,
calls: usize,
opts: Options,
done: bool,
}
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum Call {
FindRef,
DisambiguatePrefix,
Reflog,
NthCheckedOutBranch,
SiblingBranch,
Traverse,
PeelUntil,
Find,
IndexLookup,
Kind,
}
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum PeelToOwned {
ObjectKind(gix_object::Kind),
ExistingObject,
RecursiveTagObject,
Path(BString),
}
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum PrefixHintOwned {
MustBeCommit,
DescribeAnchor { ref_name: BString, generation: usize },
}
impl Recorder {
fn with(options: Options) -> Self {
Recorder {
opts: options,
..Default::default()
}
}
fn get_ref(&self, idx: usize) -> &BStr {
self.find_ref[idx].as_ref().map(AsRef::as_ref).unwrap()
}
fn called(&mut self, f: Call) {
self.calls += 1;
self.order.push(f);
}
}
fn set_val<T: std::fmt::Debug>(fn_name: &str, store: &mut [Option<T>; 2], val: T) -> Result<(), Exn> {
for entry in store.iter_mut() {
if entry.is_none() {
*entry = Some(val);
return Ok(());
}
}
panic!("called {fn_name}() more than twice with '{val:?}'");
}
impl delegate::Revision for Recorder {
fn find_ref(&mut self, input: &BStr) -> Result<(), Exn> {
self.called(Call::FindRef);
set_val("find_ref", &mut self.find_ref, input.into())
}
fn disambiguate_prefix(
&mut self,
input: gix_hash::Prefix,
hint: Option<delegate::PrefixHint<'_>>,
) -> Result<(), Exn> {
self.called(Call::DisambiguatePrefix);
if self.opts.reject_prefix {
bail!(message!("disambiguate_prefix rejected").raise_erased());
}
set_val("disambiguate_prefix", &mut self.prefix, input)?;
if let Some(hint) = hint {
set_val(
"disambiguate_prefix",
&mut self.prefix_hint,
match hint {
delegate::PrefixHint::DescribeAnchor { ref_name, generation } => PrefixHintOwned::DescribeAnchor {
ref_name: ref_name.into(),
generation,
},
delegate::PrefixHint::MustBeCommit => PrefixHintOwned::MustBeCommit,
},
)?;
}
Ok(())
}
fn reflog(&mut self, entry: delegate::ReflogLookup) -> Result<(), Exn> {
self.called(Call::Reflog);
set_val(
"current_branch_reflog",
&mut self.current_branch_reflog_entry,
match entry {
delegate::ReflogLookup::Entry(no) => no.to_string(),
delegate::ReflogLookup::Date(time) => {
let mut buf = Vec::new();
time.write_to(&mut buf).unwrap();
BString::from(buf).to_string()
}
},
)
}
fn nth_checked_out_branch(&mut self, branch: usize) -> Result<(), Exn> {
assert_ne!(branch, 0);
self.called(Call::NthCheckedOutBranch);
set_val("nth_checked_out_branch", &mut self.nth_checked_out_branch, branch)
}
fn sibling_branch(&mut self, kind: delegate::SiblingBranch) -> Result<(), Exn> {
self.called(Call::SiblingBranch);
set_val("sibling_branch", &mut self.sibling_branch, format!("{kind:?}"))
}
}
impl delegate::Navigate for Recorder {
fn traverse(&mut self, kind: delegate::Traversal) -> Result<(), Exn> {
self.called(Call::Traverse);
self.traversal.push(kind);
Ok(())
}
fn peel_until(&mut self, kind: delegate::PeelTo) -> Result<(), Exn> {
self.called(Call::PeelUntil);
self.peel_to.push(match kind {
delegate::PeelTo::ObjectKind(kind) => PeelToOwned::ObjectKind(kind),
delegate::PeelTo::ValidObject => PeelToOwned::ExistingObject,
delegate::PeelTo::Path(path) => PeelToOwned::Path(path.into()),
delegate::PeelTo::RecursiveTagObject => PeelToOwned::RecursiveTagObject,
});
Ok(())
}
fn find(&mut self, regex: &BStr, negated: bool) -> Result<(), Exn> {
self.called(Call::Find);
self.patterns.push((regex.into(), negated));
Ok(())
}
fn index_lookup(&mut self, path: &BStr, stage: u8) -> Result<(), Exn> {
self.called(Call::IndexLookup);
self.index_lookups.push((path.into(), stage));
Ok(())
}
}
impl delegate::Kind for Recorder {
fn kind(&mut self, kind: spec::Kind) -> Result<(), Exn> {
self.called(Call::Kind);
if self.opts.reject_kind {
bail!(message!("kind() was rejected").raise_erased());
}
if self.kind.is_none() {
self.kind = Some(kind);
} else if !self.opts.no_internal_assertions {
panic!("called kind more than once with '{kind:?}'");
}
Ok(())
}
}
impl Delegate for Recorder {
fn done(&mut self) -> Result<(), Exn> {
self.done = true;
Ok(())
}
}
fn parse(spec: &str) -> Recorder {
try_parse_opts(spec, Options::default()).unwrap()
}
fn try_parse(spec: &str) -> Result<Recorder, Exn<spec::parse::Error>> {
try_parse_opts(spec, Default::default())
}
fn try_parse_opts(spec: &str, options: Options) -> Result<Recorder, Exn<spec::parse::Error>> {
let mut rec = Recorder::with(options);
spec::parse(spec.into(), &mut rec)?;
Ok(rec)
}
#[test]
fn empty_specs_are_valid() {
// they should of course be invalid for the delegate. CLIs may pre-process the input as well if they wish
// but git itself doesn't do that.
for spec in [" ", "\n\t"] {
let rec = parse(spec);
assert_eq!(rec.calls, 1);
}
let rec = parse("");
assert_eq!(rec.calls, 0, "but we do not bother to call the delegate with nothing");
assert!(rec.done);
}
#[test]
fn all_characters_are_taken_verbatim_which_includes_whitespace() {
let spec = " HEAD \n";
let rec = parse(spec);
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), spec);
}
mod fuzz {
use crate::spec::parse::{try_parse_opts, Options};
#[test]
fn failures() {
for spec in [
"@{6255520 day ago}: ",
"|^--",
"^^-^",
"^^-",
":/!-",
"A6a^-09223372036854775808",
"^^^^^^-(",
] {
drop(
try_parse_opts(
spec,
Options {
no_internal_assertions: true,
..Default::default()
},
)
.unwrap_err(),
);
}
}
}
mod anchor;
mod kind;
mod navigate;

View File

@@ -0,0 +1,255 @@
use gix_revision::spec::parse::delegate::Traversal;
use crate::spec::parse::{parse, try_parse, PeelToOwned as PeelTo};
#[test]
fn single_is_first_parent() {
let rec = parse("@^");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.traversal[0], Traversal::NthParent(1));
assert_eq!(rec.calls, 2);
}
#[test]
fn multiple_calls_stack() {
let rec = parse("@^^^10^0^{tag}^020");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(
rec.traversal,
vec![
Traversal::NthParent(1),
Traversal::NthParent(1),
Traversal::NthParent(10),
Traversal::NthParent(20),
]
);
assert_eq!(
rec.peel_to,
vec![
PeelTo::ObjectKind(gix_object::Kind::Commit),
PeelTo::ObjectKind(gix_object::Kind::Tag)
]
);
assert_eq!(rec.calls, 7);
}
#[test]
fn followed_by_zero_is_peeling_to_commit() {
let rec = parse("@^0");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.traversal.len(), 0, "traversals by parent are never zero");
assert_eq!(
rec.peel_to,
vec![PeelTo::ObjectKind(gix_object::Kind::Commit)],
"instead 0 serves as shortcut"
);
assert_eq!(rec.calls, 2);
}
#[test]
fn explicitly_positive_numbers_are_invalid() {
let err = try_parse("@^+1").unwrap_err().into_inner();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(b"+1".as_ref()));
assert!(err.message.contains("positive numbers are invalid"));
}
#[test]
fn explicit_parent_number() {
for (spec, expected) in [
("HEAD^1", 1),
("abcd^10", 10),
("v1.3.4^123", 123),
("v1.3.4-12-g1234^1000", 1000),
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert!(rec.find_ref[0].as_ref().is_some() || rec.prefix[0].is_some());
assert_eq!(rec.traversal, vec![Traversal::NthParent(expected)]);
assert_eq!(rec.calls, 2);
}
}
#[test]
fn peel_to_object_type() {
for (spec, expected) in [
("HEAD^{commit}", PeelTo::ObjectKind(gix_object::Kind::Commit)),
("abcd^{tree}", PeelTo::ObjectKind(gix_object::Kind::Tree)),
("v1.3.4^{blob}", PeelTo::ObjectKind(gix_object::Kind::Blob)),
("v1.3.4-12-g1234^{tag}", PeelTo::ObjectKind(gix_object::Kind::Tag)),
("v1.3.4-12-g1234^{object}", PeelTo::ExistingObject),
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert!(rec.find_ref[0].as_ref().is_some() || rec.prefix[0].is_some());
assert_eq!(rec.peel_to, vec![expected]);
assert_eq!(rec.calls, 2);
}
}
#[test]
fn regex_backslash_rules() {
for (spec, regex, msg) in [
(
r#"@^{/with count{1}}"#,
r#"with count{1}"#,
"matching inner parens do not need escaping",
),
(
r"@^{/with count\{1\}}",
r#"with count{1}"#,
"escaped parens are entirely ignored",
),
(r"@^{/1\}}", r#"1}"#, "unmatched closing parens need to be escaped"),
(r"@^{/2\{}", r#"2{"#, "unmatched opening parens need to be escaped"),
(
r"@^{/3{\{}}",
r#"3{{}"#,
"unmatched nested opening parens need to be escaped",
),
(
r"@^{/4{\}}}",
r#"4{}}"#,
"unmatched nested closing parens need to be escaped",
),
(r"@^{/a\b\c}", r"a\b\c", "single backslashes do not need to be escaped"),
(
r"@^{/a\b\c\\}",
r"a\b\c\",
"single backslashes do not need to be escaped, trailing",
),
(
r"@^{/a\\b\\c\\}",
r"a\b\c\",
"backslashes can be escaped nonetheless, trailing",
),
(
r"@^{/5\\{}}",
r"5\{}",
"backslashes in front of parens must be escaped or they would unbalance the brace pair",
),
] {
let rec = try_parse(spec).expect(msg);
assert!(rec.kind.is_none());
assert!(rec.find_ref[0].as_ref().is_some() || rec.prefix[0].is_some());
assert_eq!(rec.patterns, vec![(regex.into(), false)], "{msg}");
assert_eq!(rec.calls, 2);
}
}
#[test]
fn regex_with_revision_starting_point_and_negation() {
for (spec, (regex, negated)) in [
("HEAD^{/simple}", ("simple", false)),
("abcd^{/!-negated}", ("negated", true)),
("v1.3.4^{/^from start}", ("^from start", false)),
(
"v1.3.4-12-g1234^{/!!leading exclamation mark}",
("!leading exclamation mark", false),
),
("v1.3.4-12-g1234^{/with count{1}}", ("with count{1}", false)),
] {
let rec = parse(spec);
assert!(rec.kind.is_none());
assert!(rec.find_ref[0].as_ref().is_some() || rec.prefix[0].is_some());
assert_eq!(rec.patterns, vec![(regex.into(), negated)]);
assert_eq!(rec.calls, 2);
}
}
#[test]
fn empty_braces_deref_a_tag() {
let rec = parse("v1.2^{}");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "v1.2");
assert_eq!(rec.peel_to, vec![PeelTo::RecursiveTagObject]);
assert_eq!(rec.calls, 2);
}
#[test]
fn invalid_object_type() {
let err = try_parse("@^{invalid}").unwrap_err().into_inner();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(b"invalid".as_ref()));
assert!(err.message.contains("cannot peel"));
let err = try_parse("@^{Commit}").unwrap_err().into_inner();
assert!(
err.input.as_ref().map(AsRef::as_ref) == Some(b"Commit".as_ref()) && err.message.contains("cannot peel"),
"these types are case sensitive"
);
}
#[test]
fn invalid_caret_without_previous_refname() {
let rec = parse(r"^^");
assert_eq!(rec.calls, 2);
assert_eq!(rec.kind, Some(gix_revision::spec::Kind::ExcludeReachable));
assert_eq!(
rec.traversal,
[Traversal::NthParent(1)],
"This can trip off an implementation as it's actually invalid, but looks valid"
);
for revspec in ["^^^HEAD", "^^HEAD"] {
let err = try_parse(revspec).unwrap_err().into_inner();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(b"HEAD".as_ref()));
assert!(err.message.contains("unconsumed input"));
}
}
#[test]
fn incomplete_escaped_braces_in_regex_are_invalid() {
let err = try_parse(r"@^{/a\{1}}").unwrap_err().into_inner();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(b"}".as_ref()));
assert!(err.message.contains("unconsumed input"));
let err = try_parse(r"@^{/a{1\}}").unwrap_err().into_inner();
assert!(
err.input.as_ref().map(AsRef::as_ref) == Some(br"{/a{1\}}".as_ref()) && err.message.contains("unclosed brace")
);
}
#[test]
fn regex_with_empty_exclamation_mark_prefix_is_invalid() {
let err = try_parse(r#"@^{/!hello}"#).unwrap_err().into_inner();
assert_eq!(err.input.as_ref().map(AsRef::as_ref), Some(b"!hello".as_ref()));
assert!(err.message.contains("need one character after"));
}
#[test]
fn bad_escapes_can_cause_brace_mismatch() {
let err = try_parse(r"@^{\}").unwrap_err().into_inner();
assert!(err.input.as_ref().map(AsRef::as_ref) == Some(br"{\}".as_ref()) && err.message.contains("unclosed brace"));
let err = try_parse(r"@^{{\}}").unwrap_err().into_inner();
// The raw string r"{{\}}" contains actual backslashes, so the input would be r"{{\}}"
assert!(
err.input.as_ref().map(AsRef::as_ref) == Some(br"{{\}}".as_ref()) && err.message.contains("unclosed brace")
);
}
#[test]
fn empty_top_revision_regex_are_skipped_as_they_match_everything() {
let rec = parse("@^{/}");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert!(
rec.patterns.is_empty(),
"The delegate won't be called with empty regexes"
);
assert_eq!(rec.calls, 1);
}

View File

@@ -0,0 +1,57 @@
use gix_revision::spec::parse::delegate::Traversal;
use crate::spec::parse::{parse, PeelToOwned as PeelTo};
#[test]
fn paths_consume_all_remaining_input_as_they_refer_to_blobs() {
let rec = parse("@:../relative/path...@^^~~");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.traversal.len(), 0);
assert_eq!(rec.peel_to, vec![PeelTo::Path("../relative/path...@^^~~".into())]);
assert_eq!(rec.calls, 2);
let rec = parse("@:absolute/path^{object}");
assert_eq!(
rec.peel_to,
vec![PeelTo::Path("absolute/path^{object}".into())],
"this includes useful navigation like object-existence, a shortcoming git shares, proper implementation needs escaping as well."
);
let rec = parse("@:absolute/path^{tree}");
assert_eq!(
rec.peel_to,
vec![PeelTo::Path("absolute/path^{tree}".into())],
"this includes useful navigation like assertion of trees/blobs, we may make this possible in future but for now are as open as git"
);
}
#[test]
fn empty_paths_refer_to_the_root_tree() {
let rec = parse("@:");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.peel_to, vec![PeelTo::Path("".into())]);
assert_eq!(rec.calls, 2);
}
#[test]
fn paths_have_to_be_last_but_stack_with_other_navigation() {
let rec = parse("HEAD@{1}~10^2^{commit}:README.md");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.current_branch_reflog_entry[0], Some("1".to_string()));
assert_eq!(rec.traversal, vec![Traversal::NthAncestor(10), Traversal::NthParent(2)]);
assert_eq!(
rec.peel_to,
vec![
PeelTo::ObjectKind(gix_object::Kind::Commit),
PeelTo::Path("README.md".into())
]
);
assert_eq!(rec.calls, 6);
}

View File

@@ -0,0 +1,3 @@
mod caret_symbol;
mod colon_symbol;
mod tilde_symbol;

View File

@@ -0,0 +1,49 @@
use gix_revision::spec::parse::delegate::Traversal;
use crate::spec::parse::{parse, try_parse};
#[test]
fn without_anchor_is_invalid() {
let err = try_parse("~").unwrap_err().into_inner();
assert!(err.message.contains("tilde needs to follow an anchor"));
}
#[test]
fn single_is_first_ancestor() {
let rec = parse("@~");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.traversal[0], Traversal::NthAncestor(1));
assert_eq!(rec.calls, 2);
}
#[test]
fn followed_by_zero_is_no_op() {
let rec = parse("@~0");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(rec.calls, 1);
}
#[test]
fn multiple_calls_stack() {
let rec = parse("@~~~10~0~020");
assert!(rec.kind.is_none());
assert_eq!(rec.get_ref(0), "HEAD");
assert_eq!(rec.prefix[0], None);
assert_eq!(
rec.traversal,
vec![
Traversal::NthAncestor(1),
Traversal::NthAncestor(1),
Traversal::NthAncestor(10),
Traversal::NthAncestor(20),
]
);
assert_eq!(rec.calls, 5);
}