Skip to content

Commit 4655864

Browse files
authored
feat(git): short_tags option for vX.Y.Z git tags (#191)
* feat(git): short_tags option for vX.Y.Z git tags enables composer-compatible releases for php projects * polish and tests * fix reviews * oops
1 parent 44146ad commit 4655864

8 files changed

Lines changed: 443 additions & 50 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
cargo/sampo: minor
3+
cargo/sampo-core: minor
4+
cargo/sampo-github-action: minor
5+
---
6+
7+
Added `git.short_tags` configuration option to create short version tags (`vX.Y.Z`) for a single package. In PHP (Packagist) projects, this enables Composer-compatible releases, with the limitation of not supporting monorepos with multiple publishable PHP packages.

crates/sampo-core/src/adapters/packagist.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const PACKAGIST_RATE_LIMIT: Duration = Duration::from_millis(200);
2121
static PACKAGIST_LAST_CALL: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
2222

2323
/// Stateless adapter for Packagist/Composer packages.
24+
///
25+
/// Packagist auto-updates from VCS tags, but Composer only recognizes `vX.Y.Z` format.
26+
/// Use `git.short_tags` config for compatibility (see README). Monorepos with multiple
27+
/// Packagist packages are not supported due to this tag format constraint.
2428
pub(super) struct PackagistAdapter;
2529

2630
impl PackagistAdapter {

crates/sampo-core/src/config.rs

Lines changed: 219 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::errors::SampoError;
22
use rustc_hash::FxHashSet;
3+
use semver::Version;
34
use std::collections::BTreeSet;
45
use std::path::Path;
56

@@ -23,6 +24,8 @@ pub struct Config {
2324
pub ignore: Vec<String>,
2425
pub git_default_branch: Option<String>,
2526
pub git_release_branches: Vec<String>,
27+
/// Package using short tag format (`v{version}`) for Packagist compatibility.
28+
pub git_short_tags: Option<String>,
2629
}
2730

2831
impl Default for Config {
@@ -42,6 +45,7 @@ impl Default for Config {
4245
ignore: Vec::new(),
4346
git_default_branch: None,
4447
git_release_branches: Vec::new(),
48+
git_short_tags: None,
4549
}
4650
}
4751
}
@@ -270,7 +274,7 @@ impl Config {
270274
}
271275
}
272276

273-
let (git_default_branch, git_release_branches) = value
277+
let (git_default_branch, git_release_branches, git_short_tags) = value
274278
.get("git")
275279
.and_then(|v| v.as_table())
276280
.map(|git_table| {
@@ -294,9 +298,16 @@ impl Config {
294298
})
295299
.unwrap_or_default();
296300

297-
(default_branch, release_branches)
301+
let short_tags = git_table
302+
.get("short_tags")
303+
.and_then(|v| v.as_str())
304+
.map(|s| s.trim())
305+
.filter(|s| !s.is_empty())
306+
.map(|s| s.to_string());
307+
308+
(default_branch, release_branches, short_tags)
298309
})
299-
.unwrap_or((None, Vec::new()));
310+
.unwrap_or((None, Vec::new(), None));
300311

301312
Ok(Self {
302313
version,
@@ -313,6 +324,7 @@ impl Config {
313324
ignore,
314325
git_default_branch,
315326
git_release_branches,
327+
git_short_tags,
316328
})
317329
}
318330

@@ -334,6 +346,50 @@ impl Config {
334346
pub fn is_release_branch(&self, branch: &str) -> bool {
335347
self.release_branches().contains(branch)
336348
}
349+
350+
/// Returns true if the given package should use short tag format (`v{version}`).
351+
pub fn uses_short_tags(&self, package_name: &str) -> bool {
352+
self.git_short_tags
353+
.as_ref()
354+
.is_some_and(|name| name == package_name)
355+
}
356+
357+
/// Builds a git tag name for the given package and version.
358+
pub fn build_tag_name(&self, package_name: &str, version: &str) -> String {
359+
if self.uses_short_tags(package_name) {
360+
format!("v{}", version)
361+
} else {
362+
format!("{}-v{}", package_name, version)
363+
}
364+
}
365+
366+
/// Parses a tag and returns (package_name, version).
367+
pub fn parse_tag(&self, tag: &str) -> Option<(String, String)> {
368+
if let Some(short_pkg) = self
369+
.git_short_tags
370+
.as_ref()
371+
.filter(|_| tag.starts_with('v'))
372+
{
373+
let version_str = tag.trim_start_matches('v');
374+
if Version::parse(version_str).is_ok() {
375+
return Some((short_pkg.clone(), version_str.to_string()));
376+
}
377+
}
378+
379+
// Iterate over all "-v" positions to handle prereleases containing "-v" (e.g., "pkg-v1.2.3-v1").
380+
for (idx, _) in tag.match_indices("-v") {
381+
let name = &tag[..idx];
382+
let version = &tag[idx + 2..];
383+
if name.is_empty() || version.is_empty() {
384+
continue;
385+
}
386+
if Version::parse(version).is_ok() {
387+
return Some((name.to_string(), version.to_string()));
388+
}
389+
}
390+
391+
None
392+
}
337393
}
338394

339395
#[cfg(test)]
@@ -356,6 +412,7 @@ mod tests {
356412
assert_eq!(config.default_branch(), "main");
357413
assert!(config.is_release_branch("main"));
358414
assert_eq!(config.git_release_branches, Vec::<String>::new());
415+
assert!(config.git_short_tags.is_none());
359416
}
360417

361418
#[test]
@@ -660,4 +717,163 @@ mod tests {
660717
vec![vec!["pkg-c".to_string(), "pkg-d".to_string()]]
661718
);
662719
}
720+
721+
#[test]
722+
fn reads_short_tags() {
723+
let temp = tempfile::tempdir().unwrap();
724+
fs::create_dir_all(temp.path().join(".sampo")).unwrap();
725+
fs::write(
726+
temp.path().join(".sampo/config.toml"),
727+
"[git]\nshort_tags = \"my-package\"\n",
728+
)
729+
.unwrap();
730+
731+
let config = Config::load(temp.path()).unwrap();
732+
assert_eq!(config.git_short_tags.as_deref(), Some("my-package"));
733+
}
734+
735+
#[test]
736+
fn defaults_short_tags_to_none() {
737+
let temp = tempfile::tempdir().unwrap();
738+
let config = Config::load(temp.path()).unwrap();
739+
assert!(config.git_short_tags.is_none());
740+
}
741+
742+
#[test]
743+
fn uses_short_tags_returns_true_for_matching_package() {
744+
let temp = tempfile::tempdir().unwrap();
745+
fs::create_dir_all(temp.path().join(".sampo")).unwrap();
746+
fs::write(
747+
temp.path().join(".sampo/config.toml"),
748+
"[git]\nshort_tags = \"my-package\"\n",
749+
)
750+
.unwrap();
751+
752+
let config = Config::load(temp.path()).unwrap();
753+
assert!(config.uses_short_tags("my-package"));
754+
assert!(!config.uses_short_tags("other-package"));
755+
}
756+
757+
#[test]
758+
fn build_tag_name_uses_short_format_for_configured_package() {
759+
let temp = tempfile::tempdir().unwrap();
760+
fs::create_dir_all(temp.path().join(".sampo")).unwrap();
761+
fs::write(
762+
temp.path().join(".sampo/config.toml"),
763+
"[git]\nshort_tags = \"my-package\"\n",
764+
)
765+
.unwrap();
766+
767+
let config = Config::load(temp.path()).unwrap();
768+
assert_eq!(config.build_tag_name("my-package", "1.2.3"), "v1.2.3");
769+
assert_eq!(
770+
config.build_tag_name("other-package", "1.2.3"),
771+
"other-package-v1.2.3"
772+
);
773+
}
774+
775+
#[test]
776+
fn parse_tag_handles_short_format() {
777+
let temp = tempfile::tempdir().unwrap();
778+
fs::create_dir_all(temp.path().join(".sampo")).unwrap();
779+
fs::write(
780+
temp.path().join(".sampo/config.toml"),
781+
"[git]\nshort_tags = \"my-package\"\n",
782+
)
783+
.unwrap();
784+
785+
let config = Config::load(temp.path()).unwrap();
786+
assert_eq!(
787+
config.parse_tag("v1.2.3"),
788+
Some(("my-package".to_string(), "1.2.3".to_string()))
789+
);
790+
assert_eq!(
791+
config.parse_tag("v1.2.3-alpha.1"),
792+
Some(("my-package".to_string(), "1.2.3-alpha.1".to_string()))
793+
);
794+
// Standard format still works
795+
assert_eq!(
796+
config.parse_tag("other-package-v1.2.3"),
797+
Some(("other-package".to_string(), "1.2.3".to_string()))
798+
);
799+
}
800+
801+
#[test]
802+
fn parse_tag_short_format_with_v_in_prerelease() {
803+
let temp = tempfile::tempdir().unwrap();
804+
fs::create_dir_all(temp.path().join(".sampo")).unwrap();
805+
fs::write(
806+
temp.path().join(".sampo/config.toml"),
807+
"[git]\nshort_tags = \"my-package\"\n",
808+
)
809+
.unwrap();
810+
811+
let config = Config::load(temp.path()).unwrap();
812+
813+
// Prerelease containing -v (the bug case)
814+
assert_eq!(
815+
config.parse_tag("v1.2.3-v1"),
816+
Some(("my-package".to_string(), "1.2.3-v1".to_string()))
817+
);
818+
assert_eq!(
819+
config.parse_tag("v1.0.0-preview1"),
820+
Some(("my-package".to_string(), "1.0.0-preview1".to_string()))
821+
);
822+
assert_eq!(
823+
config.parse_tag("v2.0.0-v2-beta"),
824+
Some(("my-package".to_string(), "2.0.0-v2-beta".to_string()))
825+
);
826+
assert_eq!(
827+
config.parse_tag("v1.2.3+build.123"),
828+
Some(("my-package".to_string(), "1.2.3+build.123".to_string()))
829+
);
830+
assert_eq!(
831+
config.parse_tag("v1.2.3-alpha.1+build.456"),
832+
Some((
833+
"my-package".to_string(),
834+
"1.2.3-alpha.1+build.456".to_string()
835+
))
836+
);
837+
}
838+
839+
#[test]
840+
fn parse_tag_rejects_invalid_short_tags() {
841+
let temp = tempfile::tempdir().unwrap();
842+
fs::create_dir_all(temp.path().join(".sampo")).unwrap();
843+
fs::write(
844+
temp.path().join(".sampo/config.toml"),
845+
"[git]\nshort_tags = \"my-package\"\n",
846+
)
847+
.unwrap();
848+
849+
let config = Config::load(temp.path()).unwrap();
850+
851+
assert_eq!(config.parse_tag("v1.2"), None);
852+
assert_eq!(config.parse_tag("vfoo"), None);
853+
assert_eq!(config.parse_tag("v01.2.3"), None);
854+
assert_eq!(config.parse_tag("v"), None);
855+
}
856+
857+
#[test]
858+
fn parse_tag_without_short_tags_config() {
859+
let temp = tempfile::tempdir().unwrap();
860+
let config = Config::load(temp.path()).unwrap();
861+
862+
assert_eq!(config.parse_tag("v1.2.3"), None);
863+
assert_eq!(
864+
config.parse_tag("my-package-v1.2.3"),
865+
Some(("my-package".to_string(), "1.2.3".to_string()))
866+
);
867+
assert_eq!(
868+
config.parse_tag("my-package-v1.2.3-alpha.1"),
869+
Some(("my-package".to_string(), "1.2.3-alpha.1".to_string()))
870+
);
871+
// -v in prerelease requires semver validation to parse correctly
872+
assert_eq!(
873+
config.parse_tag("my-package-v1.2.3-v1"),
874+
Some(("my-package".to_string(), "1.2.3-v1".to_string()))
875+
);
876+
assert_eq!(config.parse_tag("my-package-vfoo"), None);
877+
assert_eq!(config.parse_tag("my-package-v1.2"), None);
878+
}
663879
}

0 commit comments

Comments
 (0)