new: add upstream-aware orig tarball origins and the orig-vendor component

This commit is contained in:
2026-09-17 01:31:35 +02:00
parent 8e06b2074d
commit 77420e723a
19 changed files with 3270 additions and 184 deletions
+533
View File
@@ -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);
}
}