new: add upstream-aware orig tarball origins and the orig-vendor component
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
//! 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 `github.com` and
|
||||
//! `gitlab.com` are recognized as forges — self-hosted GitLab instances
|
||||
//! are deliberately not (the release-download URL shapes differ).
|
||||
//!
|
||||
//! Detection never touches the network: adding a remote stores its URL in
|
||||
//! the local config only, which is all this module reads.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// A forge hosting the project, parsed from the `origin` remote URL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Forge {
|
||||
/// `github.com/<owner>/<repo>`
|
||||
GitHub {
|
||||
/// Repository owner (user or organization).
|
||||
owner: String,
|
||||
/// Repository name, without the `.git` suffix.
|
||||
repo: String,
|
||||
},
|
||||
/// `gitlab.com/<owner>/<repo>`
|
||||
GitLab {
|
||||
/// Repository owner (user or group).
|
||||
owner: String,
|
||||
/// Repository name, without the `.git` suffix.
|
||||
repo: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Forge {
|
||||
/// Host name of the forge.
|
||||
pub fn host(&self) -> &'static str {
|
||||
match self {
|
||||
Forge::GitHub { .. } => "github.com",
|
||||
Forge::GitLab { .. } => "gitlab.com",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a remote URL into a [`Forge`], accepting the `https://`,
|
||||
/// `http://`, `git://` and `git@host:` spellings. Only `github.com` and
|
||||
/// `gitlab.com` 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;
|
||||
}
|
||||
match host {
|
||||
"github.com" => Some(Forge::GitHub {
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
}),
|
||||
"gitlab.com" => Some(Forge::GitLab {
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Release-tarball URLs of `tag`, best candidate first. GitHub prefers
|
||||
/// the codeload direct link (no redirect) and falls back to the
|
||||
/// `github.com` archive URL; GitLab has a single archive URL.
|
||||
pub fn release_tarball_urls(&self, tag: &str) -> Vec<String> {
|
||||
match self {
|
||||
Forge::GitHub { owner, repo } => vec![
|
||||
format!("https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}"),
|
||||
format!("https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz"),
|
||||
],
|
||||
Forge::GitLab { owner, repo } => {
|
||||
vec![format!(
|
||||
"https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz"
|
||||
)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::GitHub {
|
||||
owner: "foo".into(),
|
||||
repo: "bar".into()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
Forge::parse("git@github.com:foo/bar.git"),
|
||||
Some(Forge::GitHub {
|
||||
owner: "foo".into(),
|
||||
repo: "bar".into()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
Forge::parse("git://github.com/foo/bar"),
|
||||
Some(Forge::GitHub {
|
||||
owner: "foo".into(),
|
||||
repo: "bar".into()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
Forge::parse("https://gitlab.com/foo/bar/-/tree/main"),
|
||||
Some(Forge::GitLab {
|
||||
owner: "foo".into(),
|
||||
repo: "bar".into()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
Forge::parse("ssh://git@gitlab.com/foo/bar.git"),
|
||||
Some(Forge::GitLab {
|
||||
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::GitHub {
|
||||
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::GitLab {
|
||||
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()]
|
||||
);
|
||||
}
|
||||
|
||||
#[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::GitHub {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user