Files
pkh/src/new/origin.rs
T

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);
}
}