Skip to content

Commit 7d619bb

Browse files
committed
Sort tags as versions by default
1 parent 9946611 commit 7d619bb

File tree

1 file changed

+184
-24
lines changed
  • gitoxide-core/src/repository

1 file changed

+184
-24
lines changed

gitoxide-core/src/repository/tag.rs

Lines changed: 184 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,193 @@
1-
pub fn list(repo: gix::Repository, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
2-
let platform = repo.references()?;
1+
use std::cmp::Ordering;
2+
3+
use gix::bstr::{BStr, ByteSlice};
4+
5+
#[derive(Eq, PartialEq)]
6+
enum VersionPart {
7+
String(String),
8+
Number(usize),
9+
}
10+
11+
impl Ord for VersionPart {
12+
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
13+
match (self, other) {
14+
(VersionPart::String(a), VersionPart::String(b)) => a.cmp(b),
15+
(VersionPart::String(_), VersionPart::Number(_)) => Ordering::Less,
16+
(VersionPart::Number(_), VersionPart::String(_)) => Ordering::Greater,
17+
(VersionPart::Number(a), VersionPart::Number(b)) => a.cmp(b),
18+
}
19+
}
20+
}
21+
22+
impl PartialOrd for VersionPart {
23+
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
24+
Some(Ord::cmp(self, other))
25+
}
26+
}
27+
28+
/// `Version` is used to store multi-part version numbers. It does so in a rather naive way,
29+
/// only distinguishing between parts that can be parsed as an integer and those that cannot.
30+
///
31+
/// `Version` does not parse version numbers in any structure-aware way, so `v0.a` is parsed into
32+
/// `v`, `0`, `.a`.
33+
///
34+
/// Comparing two `Version`s comes down to comparing their `parts`. `parts` are either compared
35+
/// numerically or lexicographically, depending on whether they are an integer or not. That way,
36+
/// `v0.9` sorts before `v0.10` as one would expect from a version number.
37+
///
38+
/// When comparing versions of different lengths, shorter versions sort before longer ones (e.g.,
39+
/// `v1.0` < `v1.0.1`). String parts always sort before numeric parts when compared directly.
40+
///
41+
/// The sorting does not respect `versionsort.suffix` yet.
42+
#[derive(Eq, PartialEq)]
43+
struct Version {
44+
parts: Vec<VersionPart>,
45+
}
346

4-
for mut reference in platform.tags()?.flatten() {
5-
let tag = reference.peel_to_tag();
6-
let tag_ref = tag.as_ref().map(gix::Tag::decode);
7-
8-
// `name` is the name of the file in `refs/tags/`.
9-
// This applies to both lightweight and annotated tags.
10-
let name = reference.name().shorten();
11-
let mut fields = Vec::new();
12-
match tag_ref {
13-
Ok(Ok(tag_ref)) => {
14-
// `tag_name` is the name provided by the user via `git tag -a/-s/-u`.
15-
// It is only present for annotated tags.
16-
fields.push(format!(
17-
"tag name: {}",
18-
if name == tag_ref.name { "*".into() } else { tag_ref.name }
19-
));
20-
if tag_ref.pgp_signature.is_some() {
21-
fields.push("signed".into());
47+
impl Version {
48+
fn parse(version: &BStr) -> Self {
49+
let parts = version
50+
.chunk_by(|a, b| a.is_ascii_digit() == b.is_ascii_digit())
51+
.map(|part| {
52+
if let Ok(part) = part.to_str() {
53+
match part.parse::<usize>() {
54+
Ok(number) => VersionPart::Number(number),
55+
Err(_) => VersionPart::String(part.to_string()),
56+
}
57+
} else {
58+
VersionPart::String(String::from_utf8_lossy(part).to_string())
2259
}
60+
})
61+
.collect();
2362

24-
writeln!(out, "{name} [{fields}]", fields = fields.join(", "))?;
25-
}
26-
_ => {
27-
writeln!(out, "{name}")?;
63+
Self { parts }
64+
}
65+
}
66+
67+
impl Ord for Version {
68+
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
69+
let mut a_iter = self.parts.iter();
70+
let mut b_iter = other.parts.iter();
71+
72+
loop {
73+
match (a_iter.next(), b_iter.next()) {
74+
(Some(a), Some(b)) => match a.cmp(b) {
75+
Ordering::Equal => continue,
76+
other => return other,
77+
},
78+
(Some(_), None) => return Ordering::Greater,
79+
(None, Some(_)) => return Ordering::Less,
80+
(None, None) => return Ordering::Equal,
2881
}
2982
}
3083
}
84+
}
85+
86+
impl PartialOrd for Version {
87+
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
88+
Some(Ord::cmp(self, other))
89+
}
90+
}
91+
92+
pub fn list(repo: gix::Repository, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
93+
let platform = repo.references()?;
94+
95+
let mut tags: Vec<_> = platform
96+
.tags()?
97+
.flatten()
98+
.map(|mut reference| {
99+
let tag = reference.peel_to_tag();
100+
let tag_ref = tag.as_ref().map(gix::Tag::decode);
101+
102+
// `name` is the name of the file in `refs/tags/`.
103+
// This applies to both lightweight and annotated tags.
104+
let name = reference.name().shorten();
105+
let mut fields = Vec::new();
106+
let version = Version::parse(name);
107+
match tag_ref {
108+
Ok(Ok(tag_ref)) => {
109+
// `tag_name` is the name provided by the user via `git tag -a/-s/-u`.
110+
// It is only present for annotated tags.
111+
fields.push(format!(
112+
"tag name: {}",
113+
if name == tag_ref.name { "*".into() } else { tag_ref.name }
114+
));
115+
if tag_ref.pgp_signature.is_some() {
116+
fields.push("signed".into());
117+
}
118+
119+
(version, format!("{name} [{fields}]", fields = fields.join(", ")))
120+
}
121+
_ => (version, name.to_string()),
122+
}
123+
})
124+
.collect();
125+
126+
tags.sort_by(|a, b| a.0.cmp(&b.0));
127+
128+
for (_, tag) in tags {
129+
writeln!(out, "{tag}")?;
130+
}
31131

32132
Ok(())
33133
}
134+
135+
#[cfg(test)]
136+
mod tests {
137+
use super::*;
138+
use gix::bstr::BStr;
139+
140+
#[test]
141+
fn sorts_versions_correctly() {
142+
let mut actual = vec![
143+
"v2.0.0",
144+
"v1.10.0",
145+
"v1.2.1",
146+
"v1.0.0-beta",
147+
"v1.2",
148+
"v0.10.0",
149+
"v0.9.0",
150+
"v1.2.0",
151+
"v0.1.a",
152+
"v0.1.0",
153+
"v10.0.0",
154+
"1.0.0",
155+
"v1.0.0-alpha",
156+
"v1.0.0",
157+
];
158+
159+
actual.sort_by(|a, b| {
160+
let version_a = Version::parse(BStr::new(a.as_bytes()));
161+
let version_b = Version::parse(BStr::new(b.as_bytes()));
162+
version_a.cmp(&version_b)
163+
});
164+
165+
let expected = vec![
166+
"v0.1.0",
167+
"v0.1.a",
168+
"v0.9.0",
169+
"v0.10.0",
170+
"v1.0.0",
171+
"v1.0.0-alpha",
172+
"v1.0.0-beta",
173+
"v1.2",
174+
"v1.2.0",
175+
"v1.2.1",
176+
"v1.10.0",
177+
"v2.0.0",
178+
"v10.0.0",
179+
"1.0.0",
180+
];
181+
182+
assert_eq!(actual, expected);
183+
}
184+
185+
#[test]
186+
fn sorts_versions_with_different_lengths_correctly() {
187+
let v1 = Version::parse(BStr::new(b"v1.0"));
188+
let v2 = Version::parse(BStr::new(b"v1.0.1"));
189+
190+
assert_eq!(v1.cmp(&v2), Ordering::Less);
191+
assert_eq!(v2.cmp(&v1), Ordering::Greater);
192+
}
193+
}

0 commit comments

Comments
 (0)