rustyphoenixcommitrelease 1.2.0

Commits, tags and pushes a Phoenix release
use anyhow::Result;
use std::ffi::OsStr;
use std::path::Path;
use std::process;

#[derive(Debug, Clone, Copy)]
pub enum GitCommandOutput {
	UnreachableDestination,
	UnexistingFile,
	NewerRevisionsAvailable,
	CommandFailed,
}

fn run<I, S>(repo_path: &Path, args: I) -> Result<String>
where
	I: IntoIterator<Item = S>,
	S: AsRef<OsStr>,
{
	let output = process::Command::new("git")
		.arg("-C").arg(repo_path).args(args)
		.output()
		.map_err(|_| GitCommandOutput::CommandFailed)?;

	if !output.status.success() {
		return Err(GitCommandOutput::CommandFailed.into());
	}

	Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

/// Marks `repo_path` as safe for the current user, so git allows operating on a checkout
/// owned by a different user (the runner checks out the repo, the CI job then runs as
/// another user in the pixi_git image, which git otherwise refuses as "dubious ownership").
pub fn trust_directory(repo_path: &Path) -> Result<()> {
	process::Command::new("git")
		.args(["config", "--global", "--add", "safe.directory"])
		.arg(repo_path)
		.status()
		.map_err(|_| GitCommandOutput::CommandFailed)?
		.success()
		.then_some(())
		.ok_or(GitCommandOutput::CommandFailed)?;
	Ok(())
}

pub fn set_remote_url(repo_path: &Path, remote: &str, url: &str) -> Result<()> {
	run(repo_path, ["remote", "set-url", remote, url]).map(|_| ())
}

pub fn add(repo_path: &Path, file: &str) -> Result<()> {
	run(repo_path, ["add", file]).map(|_| ())
}

/// Commits with a fixed bot identity (matching semantic-release/git's historical convention),
/// set through `-c` rather than `git config` so it never touches the repo's persistent config.
pub fn commit(repo_path: &Path, author_name: &str, author_email: &str, message: &str) -> Result<()> {
	run(
		repo_path,
		[
			"-c".to_string(),
			format!("user.name={author_name}"),
			"-c".to_string(),
			format!("user.email={author_email}"),
			"commit".to_string(),
			"-m".to_string(),
			message.to_string(),
		],
	)
	.map(|_| ())
}

pub fn push(repo_path: &Path, remote: &str, refspec: &str) -> Result<()> {
	run(repo_path, ["push", remote, refspec]).map(|_| ())
}

pub fn tag(repo_path: &Path, name: &str) -> Result<()> {
	run(repo_path, ["tag", name]).map(|_| ())
}

pub fn rev_parse_head(repo_path: &Path) -> Result<String> {
	Ok(run(repo_path, ["rev-parse", "HEAD"])?.trim().to_string())
}

#[cfg(test)]
mod tests {
	use super::*;
	use std::fs;
	use tempfile::{tempdir, TempDir};

	fn init_repo() -> TempDir {
		let dir = tempdir().unwrap();
		process::Command::new("git").arg("-C").arg(dir.path()).args(["init", "-q"]).status().unwrap();
		process::Command::new("git")
			.arg("-C")
			.arg(dir.path())
			.args(["config", "user.email", "test@test.com"])
			.status()
			.unwrap();
		process::Command::new("git")
			.arg("-C")
			.arg(dir.path())
			.args(["config", "user.name", "test"])
			.status()
			.unwrap();
		dir
	}

	#[test]
	fn add_commit_and_rev_parse_head() {
		let dir = init_repo();
		fs::write(dir.path().join("file.txt"), "hello").unwrap();

		add(dir.path(), "file.txt").unwrap();
		commit(dir.path(), "Phoenix CI", "ci@phoenix.invalid", "chore(release): 1.0.0 [skip ci]").unwrap();

		let sha = rev_parse_head(dir.path()).unwrap();
		assert_eq!(sha.len(), 40);
	}

	#[test]
	fn commit_uses_the_given_author_identity() {
		let dir = init_repo();
		fs::write(dir.path().join("file.txt"), "hello").unwrap();
		add(dir.path(), "file.txt").unwrap();
		commit(dir.path(), "Phoenix CI", "ci@phoenix.invalid", "chore(release): 1.0.0 [skip ci]").unwrap();

		let log = run(dir.path(), ["log", "-1", "--pretty=format:%an <%ae>"]).unwrap();
		assert_eq!(log, "Phoenix CI <ci@phoenix.invalid>");
	}

	#[test]
	fn tag_creates_a_tag_pointing_at_head() {
		let dir = init_repo();
		fs::write(dir.path().join("file.txt"), "hello").unwrap();
		add(dir.path(), "file.txt").unwrap();
		commit(dir.path(), "n", "e", "init").unwrap();

		tag(dir.path(), "1.0.0").unwrap();

		let tags = run(dir.path(), ["tag", "--list"]).unwrap();
		assert!(tags.contains("1.0.0"));
	}

	#[test]
	fn set_remote_url_updates_the_config() {
		let dir = init_repo();
		run(dir.path(), ["remote", "add", "origin", "https://example.com/old.git"]).unwrap();

		set_remote_url(dir.path(), "origin", "https://example.com/new.git").unwrap();

		let url = run(dir.path(), ["remote", "get-url", "origin"]).unwrap();
		assert_eq!(url.trim(), "https://example.com/new.git");
	}
}