//! 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 //! `+git.` 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, } /// The bundled forge table (`data/forges.yml`): host name → entry #[derive(Debug, Deserialize)] struct ForgesData { /// Recognized forge hosts forges: HashMap, } 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 { 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 { 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, /// Tag exactly at HEAD, when there is one (name as written, e.g. `v1.2.3`). pub head_tag: Option, /// Every tag of the repository, sorted lexically. pub tags: Vec, /// Last tag reachable from HEAD (used by [`GitOrigin::git_version`]). pub last_tag: Option, /// HEAD commit date as `%Y%m%d`. pub head_date: Option, /// HEAD commit short hash. pub head_hash: Option, /// 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 { // 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 { sanitized_tag_version(self.head_tag.as_deref()?) } /// Version suggestion for a HEAD between releases: /// `+git.` (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 { 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 { 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> { 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 { 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::("kind: github").is_err()); assert!( serde_yaml::from_str::( "kind: github\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']" ) .is_ok() ); assert!( serde_yaml::from_str::( "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); } }