618 lines
22 KiB
Rust
618 lines
22 KiB
Rust
//! Best-effort git origin detection for `pkh new`.
|
|
//!
|
|
//! [`GitOrigin::detect`] inspects the source directory through the `git`
|
|
//! CLI (fail-soft: any failed query just leaves the corresponding field
|
|
//! empty, and a non-repo yields `None`) and answers the questions driving
|
|
//! the source-format and orig-tarball decisions:
|
|
//!
|
|
//! - is HEAD exactly on a tag, and which upstream version does it name,
|
|
//! - which is the last tag reachable from HEAD (for the
|
|
//! `<tag>+git<YYYYMMDD>.<hash>` version scheme),
|
|
//! - where does the `origin` remote point: only the hosts of the bundled
|
|
//! forge table (`data/forges.yml`) are recognized as forges — self-hosted
|
|
//! GitLab instances are deliberately not part of it (the
|
|
//! release-download URL shapes differ), and the table also carries each
|
|
//! forge's tarball URL templates.
|
|
//!
|
|
//! Detection never touches the network: adding a remote stores its URL in
|
|
//! the local config only, which is all this module reads.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::data::embed_data;
|
|
|
|
/// Family a forge belongs to. Documentation of which URL-shape family a
|
|
/// table entry belongs to — the tarball templates fully describe the
|
|
/// URLs, so nothing branches on the kind (yet). Parsed strictly: an
|
|
/// unknown kind fails the load rather than being silently ignored.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum ForgeKind {
|
|
/// `github.com` and its codeload archive service
|
|
GitHub,
|
|
/// `gitlab.com`
|
|
GitLab,
|
|
}
|
|
|
|
/// One entry of the bundled forge table: the release-tarball URL
|
|
/// templates of a forge host
|
|
#[derive(Debug, Deserialize)]
|
|
struct ForgeEntry {
|
|
/// Family of the forge (see [`ForgeKind`]). Kept even though nothing
|
|
/// branches on it (the templates fully describe the URLs): it
|
|
/// documents the entry's URL-shape family and the strict enum
|
|
/// validates it at load time.
|
|
#[allow(dead_code)]
|
|
kind: ForgeKind,
|
|
/// Release-tarball URL templates, best candidate first (tried
|
|
/// sequentially by the download)
|
|
tarball_templates: Vec<String>,
|
|
}
|
|
|
|
/// The bundled forge table (`data/forges.yml`): host name → entry
|
|
#[derive(Debug, Deserialize)]
|
|
struct ForgesData {
|
|
/// Recognized forge hosts
|
|
forges: HashMap<String, ForgeEntry>,
|
|
}
|
|
|
|
embed_data! {
|
|
static ref FORGES_DATA: ForgesData = "../../data/forges.yml"
|
|
}
|
|
|
|
/// A forge hosting the project, parsed from the `origin` remote URL: one
|
|
/// of the hosts of the bundled forge table, plus the repository it points
|
|
/// at.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Forge {
|
|
/// Host name of the forge (the key of its `forges.yml` entry).
|
|
host: &'static str,
|
|
/// Repository owner (user or organization).
|
|
owner: String,
|
|
/// Repository name, without the `.git` suffix.
|
|
repo: String,
|
|
}
|
|
|
|
impl Forge {
|
|
/// Host name of the forge.
|
|
pub fn host(&self) -> &'static str {
|
|
self.host
|
|
}
|
|
|
|
/// Parse a remote URL into a [`Forge`], accepting the `https://`,
|
|
/// `http://`, `git://` and `git@host:` spellings. Only the hosts
|
|
/// listed in the bundled forge table are recognized; anything else
|
|
/// (self-hosted GitLab, Bitbucket, plain URLs…) yields `None`.
|
|
pub fn parse(url: &str) -> Option<Forge> {
|
|
let url = url.trim();
|
|
// Normalize `git@host:path` to `host/path` and strip any scheme.
|
|
let (host, path) = match url.split_once("://") {
|
|
Some((_scheme, rest)) => rest.split_once('/')?,
|
|
None => {
|
|
let (scp, path) = url.split_once(':')?;
|
|
let host = scp.strip_prefix("git@").unwrap_or(scp);
|
|
(host, path)
|
|
}
|
|
};
|
|
let host = host.to_ascii_lowercase();
|
|
// Drop the leading user part of ssh URLs (`git@github.com` handled
|
|
// above; `ssh://git@github.com/path` keeps `git@github.com` here).
|
|
let host = host.rsplit('@').next().unwrap_or(&host);
|
|
let mut segments = path
|
|
.trim_end_matches('/')
|
|
.trim_end_matches(".git")
|
|
.split('/');
|
|
let owner = segments.next()?;
|
|
let repo = segments.next()?;
|
|
if owner.is_empty() || repo.is_empty() {
|
|
return None;
|
|
}
|
|
// The table key doubles as the Forge's host, so the two can never
|
|
// disagree about how the forge is spelled.
|
|
let (host, _entry) = FORGES_DATA.forges.get_key_value(host)?;
|
|
Some(Forge {
|
|
host,
|
|
owner: owner.to_string(),
|
|
repo: repo.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Release-tarball URLs of `tag`, best candidate first: the templates
|
|
/// of the forge's table entry, with `{owner}`, `{repo}` and `{tag}`
|
|
/// substituted, in file order (the download tries them sequentially).
|
|
pub fn release_tarball_urls(&self, tag: &str) -> Vec<String> {
|
|
let entry = FORGES_DATA
|
|
.forges
|
|
.get(self.host)
|
|
.expect("the host of a parsed Forge is a key of the forge table");
|
|
entry
|
|
.tarball_templates
|
|
.iter()
|
|
.map(|template| {
|
|
template
|
|
.replace("{owner}", &self.owner)
|
|
.replace("{repo}", &self.repo)
|
|
.replace("{tag}", tag)
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// Best-effort snapshot of the git state of a source directory (see the
|
|
/// module docs). Every field degrades to its empty value when the
|
|
/// corresponding query fails.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct GitOrigin {
|
|
/// Forge of the `origin` remote, when it is one of the recognized ones.
|
|
pub forge: Option<Forge>,
|
|
/// Tag exactly at HEAD, when there is one (name as written, e.g. `v1.2.3`).
|
|
pub head_tag: Option<String>,
|
|
/// Every tag of the repository, sorted lexically.
|
|
pub tags: Vec<String>,
|
|
/// Last tag reachable from HEAD (used by [`GitOrigin::git_version`]).
|
|
pub last_tag: Option<String>,
|
|
/// HEAD commit date as `%Y%m%d`.
|
|
pub head_date: Option<String>,
|
|
/// HEAD commit short hash.
|
|
pub head_hash: Option<String>,
|
|
/// Whether the worktree carries uncommitted changes (`git status
|
|
/// --porcelain` non-empty).
|
|
pub dirty: bool,
|
|
}
|
|
|
|
impl GitOrigin {
|
|
/// Detect the git origin state of `dir`. Returns `None` when `dir` is
|
|
/// not inside a git work tree (or git cannot be run); every other
|
|
/// failure is fail-soft (the field stays empty).
|
|
pub fn detect(dir: &Path) -> Option<GitOrigin> {
|
|
// Inside a work tree? (fails outside git; prints "false" in a bare
|
|
// repository).
|
|
if git(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() != Some("true") {
|
|
return None;
|
|
}
|
|
|
|
let mut origin = GitOrigin {
|
|
head_tag: git(dir, &["describe", "--tags", "--exact-match", "HEAD"]),
|
|
tags: git(dir, &["tag", "--list"])
|
|
.map(|out| {
|
|
out.lines()
|
|
.map(str::to_string)
|
|
.filter(|line| !line.is_empty())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default(),
|
|
last_tag: git(dir, &["describe", "--tags", "--abbrev=0"]),
|
|
head_date: git(dir, &["log", "-1", "--date=format:%Y%m%d", "--format=%cd"]),
|
|
head_hash: git(dir, &["rev-parse", "--short", "HEAD"]),
|
|
dirty: git(dir, &["status", "--porcelain"]).is_some_and(|out| !out.trim().is_empty()),
|
|
forge: None,
|
|
};
|
|
|
|
if let Some(url) = git(dir, &["remote", "get-url", "origin"])
|
|
.or_else(|| git(dir, &["config", "--get", "remote.origin.url"]))
|
|
{
|
|
origin.forge = Forge::parse(&url);
|
|
}
|
|
|
|
Some(origin)
|
|
}
|
|
|
|
/// Upstream version named by the tag at HEAD: the tag with a leading
|
|
/// `v`/`V` (before a digit) stripped, and only when the result is a
|
|
/// plausible Debian upstream version (no `-`, which is the revision
|
|
/// separator).
|
|
pub fn head_tag_version(&self) -> Option<String> {
|
|
sanitized_tag_version(self.head_tag.as_deref()?)
|
|
}
|
|
|
|
/// Version suggestion for a HEAD between releases:
|
|
/// `<lasttag>+git<YYYYMMDD>.<shorthash>` (e.g. `1.2.3+git20260916.4b8a2f1`),
|
|
/// or `None` without a reachable tag, tag-strippable version, date or hash.
|
|
pub fn git_version(&self) -> Option<String> {
|
|
let base = sanitized_tag_version(self.last_tag.as_deref()?)?;
|
|
let date = self.head_date.as_deref()?;
|
|
let hash = self.head_hash.as_deref()?;
|
|
Some(format!("{base}+git{date}.{hash}"))
|
|
}
|
|
|
|
/// The repository tag naming `version` (a leading `v`/`V` on the tag is
|
|
/// ignored), whatever the state of HEAD.
|
|
pub fn tag_for_version(&self, version: &str) -> Option<&str> {
|
|
self.tags
|
|
.iter()
|
|
.map(String::as_str)
|
|
.find(|tag| sanitized_tag_version(tag).as_deref() == Some(version))
|
|
}
|
|
}
|
|
|
|
/// Strip a leading `v`/`V` (before a digit) off a tag name and keep only
|
|
/// results usable as a Debian upstream version: no `-` (which is the revision
|
|
/// separator) and no leading non-digit.
|
|
fn sanitized_tag_version(tag: &str) -> Option<String> {
|
|
let stripped = tag
|
|
.strip_prefix(['v', 'V'])
|
|
.filter(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
|
|
.unwrap_or(tag);
|
|
if stripped.starts_with(|c: char| c.is_ascii_digit()) && !stripped.contains('-') {
|
|
Some(stripped.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Check out `tag` in the repository at `dir`, refusing a dirty worktree:
|
|
/// pkh never carries uncommitted changes across a checkout. Detached HEAD
|
|
/// is the expected outcome when packaging a release tag.
|
|
pub fn checkout_tag(dir: &Path, tag: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
if let Some(status) = git(dir, &["status", "--porcelain"])
|
|
&& !status.trim().is_empty()
|
|
{
|
|
return Err(format!(
|
|
"The working tree of '{}' has uncommitted changes: commit or \
|
|
stash them before checking out '{tag}' (pkh does not carry \
|
|
changes across a checkout)",
|
|
dir.display()
|
|
)
|
|
.into());
|
|
}
|
|
let output = Command::new("git")
|
|
.args(["checkout", tag])
|
|
.current_dir(dir)
|
|
.output()
|
|
.map_err(|e| format!("failed to run 'git checkout': {e}"))?;
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"'git checkout {tag}' failed: {}",
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
)
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Run `git` with `args` in `dir`, returning its trimmed stdout when it
|
|
/// exits successfully (empty output stays an empty string).
|
|
fn git(dir: &Path, args: &[&str]) -> Option<String> {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(dir)
|
|
.output()
|
|
.ok()?;
|
|
output
|
|
.status
|
|
.success()
|
|
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
/// Whether the host has a usable git CLI (every fixture below needs it).
|
|
fn have_git() -> bool {
|
|
Command::new("git")
|
|
.arg("--version")
|
|
.output()
|
|
.is_ok_and(|output| output.status.success())
|
|
}
|
|
|
|
/// Run git in `dir`, failing the test on error, with a deterministic
|
|
/// identity and no signing so host git config cannot break the fixture.
|
|
fn git(dir: &Path, args: &[&str]) {
|
|
let status = Command::new("git")
|
|
.args([
|
|
"-c",
|
|
"user.name=Pkh Origin",
|
|
"-c",
|
|
"user.email=pkhorigin@example.invalid",
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
])
|
|
.args(args)
|
|
.current_dir(dir)
|
|
.env("GIT_AUTHOR_DATE", "2026-09-15T12:00:00Z")
|
|
.env("GIT_COMMITTER_DATE", "2026-09-15T12:00:00Z")
|
|
.status()
|
|
.expect("git should be runnable");
|
|
assert!(status.success(), "git {args:?} failed");
|
|
}
|
|
|
|
/// A repository with a single commit on 2026-09-15, returning its
|
|
/// short HEAD hash.
|
|
fn init_repo(dir: &Path) -> String {
|
|
git(dir, &["init", "-q"]);
|
|
std::fs::write(dir.join("file.txt"), "one\n").unwrap();
|
|
git(dir, &["add", "file.txt"]);
|
|
git(dir, &["commit", "-q", "-m", "first"]);
|
|
git_out(dir, &["rev-parse", "--short", "HEAD"])
|
|
}
|
|
|
|
#[test]
|
|
fn forge_parses_remote_url_shapes() {
|
|
assert_eq!(
|
|
Forge::parse("https://github.com/foo/bar.git"),
|
|
Some(Forge {
|
|
host: "github.com",
|
|
owner: "foo".into(),
|
|
repo: "bar".into()
|
|
})
|
|
);
|
|
assert_eq!(
|
|
Forge::parse("git@github.com:foo/bar.git"),
|
|
Some(Forge {
|
|
host: "github.com",
|
|
owner: "foo".into(),
|
|
repo: "bar".into()
|
|
})
|
|
);
|
|
assert_eq!(
|
|
Forge::parse("git://github.com/foo/bar"),
|
|
Some(Forge {
|
|
host: "github.com",
|
|
owner: "foo".into(),
|
|
repo: "bar".into()
|
|
})
|
|
);
|
|
assert_eq!(
|
|
Forge::parse("https://gitlab.com/foo/bar/-/tree/main"),
|
|
Some(Forge {
|
|
host: "gitlab.com",
|
|
owner: "foo".into(),
|
|
repo: "bar".into()
|
|
})
|
|
);
|
|
assert_eq!(
|
|
Forge::parse("ssh://git@gitlab.com/foo/bar.git"),
|
|
Some(Forge {
|
|
host: "gitlab.com",
|
|
owner: "foo".into(),
|
|
repo: "bar".into()
|
|
})
|
|
);
|
|
// Self-hosted GitLab instances and other hosts are not recognized.
|
|
assert_eq!(Forge::parse("https://gitlab.example.com/foo/bar.git"), None);
|
|
assert_eq!(Forge::parse("https://bitbucket.org/foo/bar.git"), None);
|
|
// Garbage.
|
|
assert_eq!(Forge::parse("not a url"), None);
|
|
assert_eq!(Forge::parse("https://github.com/onlyowner"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn forge_release_tarball_urls() {
|
|
let gh = Forge {
|
|
host: "github.com",
|
|
owner: "foo".into(),
|
|
repo: "bar".into(),
|
|
};
|
|
assert_eq!(
|
|
gh.release_tarball_urls("v1.2.3"),
|
|
vec![
|
|
"https://codeload.github.com/foo/bar/tar.gz/refs/tags/v1.2.3".to_string(),
|
|
"https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz".to_string(),
|
|
]
|
|
);
|
|
let gl = Forge {
|
|
host: "gitlab.com",
|
|
owner: "foo".into(),
|
|
repo: "bar".into(),
|
|
};
|
|
assert_eq!(
|
|
gl.release_tarball_urls("v1.2.3"),
|
|
vec!["https://gitlab.com/foo/bar/-/archive/v1.2.3/bar-v1.2.3.tar.gz".to_string()]
|
|
);
|
|
}
|
|
|
|
/// Every table entry must be substitutable: a template missing one of
|
|
/// the placeholders would download from a literal `{name}` URL.
|
|
#[test]
|
|
fn forge_templates_carry_every_placeholder() {
|
|
for (host, entry) in &FORGES_DATA.forges {
|
|
assert!(
|
|
!entry.tarball_templates.is_empty(),
|
|
"forge '{host}' has no tarball template"
|
|
);
|
|
for template in &entry.tarball_templates {
|
|
for placeholder in ["{owner}", "{repo}", "{tag}"] {
|
|
assert!(
|
|
template.contains(placeholder),
|
|
"template '{template}' of forge '{host}' lacks {placeholder}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `kind` field is validated at load time: an unknown forge family
|
|
/// fails the parse instead of being silently accepted.
|
|
#[test]
|
|
fn forge_kind_is_validated() {
|
|
assert!(serde_yaml::from_str::<ForgeEntry>("kind: github").is_err());
|
|
assert!(
|
|
serde_yaml::from_str::<ForgeEntry>(
|
|
"kind: github\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']"
|
|
)
|
|
.is_ok()
|
|
);
|
|
assert!(
|
|
serde_yaml::from_str::<ForgeEntry>(
|
|
"kind: codeberg\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']"
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sanitized_tag_versions() {
|
|
assert_eq!(sanitized_tag_version("v1.2.3"), Some("1.2.3".to_string()));
|
|
assert_eq!(sanitized_tag_version("V2.0"), Some("2.0".to_string()));
|
|
assert_eq!(sanitized_tag_version("1.2.3"), Some("1.2.3".to_string()));
|
|
// `v` followed by a non-digit is part of the name, not a marker.
|
|
assert_eq!(sanitized_tag_version("version-1"), None);
|
|
// `-` would collide with the Debian revision separator.
|
|
assert_eq!(sanitized_tag_version("v1.2.3-beta"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_outside_a_repository_is_none() {
|
|
if !have_git() {
|
|
return;
|
|
}
|
|
let dir = tempdir().unwrap();
|
|
assert_eq!(GitOrigin::detect(dir.path()), None);
|
|
// A bare repository is not a work tree either.
|
|
let bare = tempdir().unwrap();
|
|
git(bare.path(), &["init", "-q", "--bare"]);
|
|
assert_eq!(GitOrigin::detect(bare.path()), None);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_plain_repo_without_tags_or_remote() {
|
|
if !have_git() {
|
|
return;
|
|
}
|
|
let dir = tempdir().unwrap();
|
|
let hash = init_repo(dir.path());
|
|
|
|
let origin = GitOrigin::detect(dir.path()).expect("detected");
|
|
assert_eq!(origin.forge, None);
|
|
assert_eq!(origin.head_tag, None);
|
|
assert!(origin.tags.is_empty());
|
|
assert_eq!(origin.last_tag, None);
|
|
assert_eq!(origin.head_hash.as_deref(), Some(hash.as_str()));
|
|
assert_eq!(origin.head_date.as_deref(), Some("20260915"));
|
|
assert!(!origin.dirty);
|
|
// No tags: no version can be derived.
|
|
assert_eq!(origin.head_tag_version(), None);
|
|
assert_eq!(origin.git_version(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_head_exactly_on_a_tag() {
|
|
if !have_git() {
|
|
return;
|
|
}
|
|
let dir = tempdir().unwrap();
|
|
init_repo(dir.path());
|
|
git(dir.path(), &["tag", "v1.2.3"]);
|
|
|
|
let origin = GitOrigin::detect(dir.path()).expect("detected");
|
|
assert_eq!(origin.head_tag.as_deref(), Some("v1.2.3"));
|
|
assert_eq!(origin.last_tag.as_deref(), Some("v1.2.3"));
|
|
assert_eq!(origin.head_tag_version().as_deref(), Some("1.2.3"));
|
|
// The query is the stripped version; the raw tag name is not one.
|
|
assert_eq!(origin.tag_for_version("1.2.3"), Some("v1.2.3"));
|
|
assert_eq!(origin.tag_for_version("v1.2.3"), None);
|
|
assert_eq!(origin.tag_for_version("9.9.9"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_between_releases_derives_git_version() {
|
|
if !have_git() {
|
|
return;
|
|
}
|
|
let dir = tempdir().unwrap();
|
|
init_repo(dir.path());
|
|
git(dir.path(), &["tag", "v1.2.3"]);
|
|
std::fs::write(dir.path().join("file.txt"), "two\n").unwrap();
|
|
git(dir.path(), &["add", "file.txt"]);
|
|
git(dir.path(), &["commit", "-q", "-m", "second"]);
|
|
let hash = git_out(dir.path(), &["rev-parse", "--short", "HEAD"]);
|
|
|
|
let origin = GitOrigin::detect(dir.path()).expect("detected");
|
|
assert_eq!(origin.head_tag, None);
|
|
assert_eq!(origin.last_tag.as_deref(), Some("v1.2.3"));
|
|
assert_eq!(origin.tag_for_version("1.2.3"), Some("v1.2.3"));
|
|
assert_eq!(
|
|
origin.git_version().as_deref(),
|
|
Some(format!("1.2.3+git20260915.{hash}").as_str())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_tracks_the_origin_remote() {
|
|
if !have_git() {
|
|
return;
|
|
}
|
|
let dir = tempdir().unwrap();
|
|
init_repo(dir.path());
|
|
// A local path remote: configuring it never touches the network.
|
|
git(
|
|
dir.path(),
|
|
&["remote", "add", "origin", "https://github.com/foo/bar.git"],
|
|
);
|
|
|
|
let origin = GitOrigin::detect(dir.path()).expect("detected");
|
|
assert_eq!(
|
|
origin.forge,
|
|
Some(Forge {
|
|
host: "github.com",
|
|
owner: "foo".into(),
|
|
repo: "bar".into()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_reports_a_dirty_worktree() {
|
|
if !have_git() {
|
|
return;
|
|
}
|
|
let dir = tempdir().unwrap();
|
|
init_repo(dir.path());
|
|
std::fs::write(dir.path().join("file.txt"), "uncommitted\n").unwrap();
|
|
|
|
let origin = GitOrigin::detect(dir.path()).expect("detected");
|
|
assert!(origin.dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn git_version_skips_unusable_tags() {
|
|
// A tag carrying `-` cannot become an upstream version: the scheme
|
|
// degrades to None instead of proposing an invalid version.
|
|
let origin = GitOrigin {
|
|
last_tag: Some("1.2.3-beta".into()),
|
|
head_date: Some("20260915".into()),
|
|
head_hash: Some("abc1234".into()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(origin.git_version(), None);
|
|
// Date or hash missing: nothing to propose either.
|
|
assert_eq!(
|
|
GitOrigin {
|
|
last_tag: Some("v2.0".into()),
|
|
..Default::default()
|
|
}
|
|
.git_version(),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// `git` output trimmed (for the hash assertions above).
|
|
fn git_out(dir: &Path, args: &[&str]) -> String {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(dir)
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "git {args:?} failed");
|
|
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
|
}
|
|
|
|
/// The tag lookup ignores a `v` prefix in either direction.
|
|
#[test]
|
|
fn tag_for_version_matches_both_spellings() {
|
|
let origin = GitOrigin {
|
|
tags: vec!["1.0".to_string(), "v2.0".to_string()],
|
|
..Default::default()
|
|
};
|
|
assert_eq!(origin.tag_for_version("1.0"), Some("1.0"));
|
|
assert_eq!(origin.tag_for_version("2.0"), Some("v2.0"));
|
|
assert_eq!(origin.tag_for_version("3.0"), None);
|
|
}
|
|
}
|