monochange 0.6.8

Manage versions and releases for your multiplatform, multilanguage monorepo
Documentation
#![allow(clippy::disallowed_methods)]
use std::fs;
use std::process::Stdio;

use tempfile::tempdir;

use super::*;

#[test]
fn git_error_message_with_detail_handles_empty_parts() {
	assert_eq!(
		git_error_message_with_detail("capture failure", "fatal"),
		"capture failure: fatal"
	);
	assert_eq!(git_error_message_with_detail("", "fatal"), "fatal");
	assert_eq!(
		git_error_message_with_detail("capture failure", ""),
		"capture failure"
	);
}

fn git(root: &Path, args: &[&str]) {
	let output = monochange_core::git::git_command(root)
		.args(args)
		.output()
		.unwrap_or_else(|error| panic!("git {args:?}: {error}"));
	assert!(
		output.status.success(),
		"git {args:?} failed: {}{}",
		String::from_utf8_lossy(&output.stdout),
		String::from_utf8_lossy(&output.stderr)
	);
}

fn git_output(root: &Path, args: &[&str]) -> String {
	let output = monochange_core::git::git_command(root)
		.args(args)
		.output()
		.unwrap_or_else(|error| panic!("git {args:?}: {error}"));
	assert!(
		output.status.success(),
		"git {args:?} failed: {}{}",
		String::from_utf8_lossy(&output.stdout),
		String::from_utf8_lossy(&output.stderr)
	);
	String::from_utf8(output.stdout)
		.unwrap_or_else(|error| panic!("git stdout utf8: {error}"))
		.trim()
		.to_string()
}

fn init_git_repo(root: &Path) {
	git(root, &["init", "-b", "main"]);
	git(root, &["config", "user.name", "monochange tests"]);
	git(root, &["config", "user.email", "monochange@example.com"]);
	git(root, &["config", "commit.gpgsign", "false"]);
}

#[tokio::test(flavor = "multi_thread")]
async fn git_commit_paths_supports_large_commit_message_bodies() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	init_git_repo(root);
	fs::write(root.join("release.txt"), "release\n")
		.unwrap_or_else(|error| panic!("write release file: {error}"));
	git(root, &["add", "release.txt"]);
	git(root, &["commit", "-m", "initial"]);

	fs::write(root.join("release.txt"), "release\nupdated\n")
		.unwrap_or_else(|error| panic!("update release file: {error}"));
	git(root, &["add", "release.txt"]);
	let body = "release record entry\n".repeat(16_384);
	git_commit_paths(
		root,
		&CommitMessage {
			subject: "chore(release): prepare release".to_string(),
			body: Some(body.clone()),
		},
		false,
	)
	.await
	.unwrap_or_else(|error| panic!("git commit paths: {error}"));

	let commit_body = git_output(root, &["log", "-1", "--format=%B"]);
	assert!(commit_body.contains("chore(release): prepare release"));
	assert!(commit_body.contains(body.trim_end()));
}

#[tokio::test(flavor = "multi_thread")]
async fn git_stage_all_stages_non_ignored_changes_only() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	init_git_repo(root);
	fs::write(
		root.join(".gitignore"),
		"ignored.txt
",
	)
	.unwrap_or_else(|error| panic!("write gitignore: {error}"));
	fs::write(
		root.join("tracked.txt"),
		"initial
",
	)
	.unwrap_or_else(|error| panic!("write tracked: {error}"));
	git(root, &["add", "."]);
	git(root, &["commit", "-m", "initial"]);

	fs::write(
		root.join("tracked.txt"),
		"updated
",
	)
	.unwrap_or_else(|error| panic!("update tracked: {error}"));
	fs::write(
		root.join("untracked.txt"),
		"new
",
	)
	.unwrap_or_else(|error| panic!("write untracked: {error}"));
	fs::write(
		root.join("ignored.txt"),
		"ignored
",
	)
	.unwrap_or_else(|error| panic!("write ignored: {error}"));

	git_stage_all(root)
		.await
		.unwrap_or_else(|error| panic!("git stage all: {error}"));

	assert_eq!(
		git_output(root, &["diff", "--cached", "--name-only"]),
		"tracked.txt
untracked.txt"
	);
	assert_eq!(
		git_output(root, &["status", "--short", "--ignored", "ignored.txt"]),
		"!! ignored.txt"
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn git_stage_paths_returns_ok_when_all_paths_are_non_stageable() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	fs::write(root.join(".gitignore"), ".monochange/\n")
		.unwrap_or_else(|error| panic!("write .gitignore: {error}"));
	fs::write(root.join("tracked.txt"), "tracked\n")
		.unwrap_or_else(|error| panic!("write tracked file: {error}"));
	fs::create_dir_all(root.join(".monochange/releases"))
		.unwrap_or_else(|error| panic!("create release state: {error}"));
	fs::write(root.join(".monochange/releases/release.json"), "{}\n")
		.unwrap_or_else(|error| panic!("write release state: {error}"));
	init_git_repo(root);
	git(root, &["add", "."]);
	git(root, &["add", "-f", ".monochange/releases/release.json"]);
	git(root, &["commit", "-m", "initial"]);
	fs::create_dir_all(root.join(".monochange/local"))
		.unwrap_or_else(|error| panic!("create .monochange: {error}"));
	fs::write(root.join(".monochange/local/release-manifest.json"), "{}\n")
		.unwrap_or_else(|error| panic!("write release manifest: {error}"));
	fs::write(
		root.join(".monochange/releases/release.json"),
		"{\"released\":true}\n",
	)
	.unwrap_or_else(|error| panic!("update release state: {error}"));
	fs::write(root.join("untracked.txt"), "untracked\n")
		.unwrap_or_else(|error| panic!("write untracked file: {error}"));

	git_stage_paths(
		root,
		&[
			PathBuf::from(".monochange/local/release-manifest.json"),
			PathBuf::from(".monochange/releases/release.json"),
			PathBuf::from("untracked.txt"),
			PathBuf::from(".changeset/missing.md"),
		],
	)
	.await
	.unwrap_or_else(|error| panic!("git stage paths: {error}"));

	assert_eq!(
		git_output(root, &["diff", "--cached", "--name-only"]),
		".monochange/releases/release.json\nuntracked.txt"
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn git_path_is_tracked_reports_command_failures() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path().to_path_buf();
	drop(tempdir);

	let error = git_path_is_tracked(&root, Path::new("release.txt"))
		.await
		.err()
		.unwrap_or_else(|| panic!("expected tracked inspection failure"));
	assert!(
		error
			.to_string()
			.contains("failed to inspect tracked git path release.txt")
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn git_path_is_ignored_reports_false_for_unignored_paths() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	init_git_repo(root);
	fs::write(root.join("release.txt"), "release\n")
		.unwrap_or_else(|error| panic!("write release file: {error}"));

	assert!(
		!git_path_is_ignored(root, Path::new("release.txt"))
			.await
			.unwrap_or_else(|error| panic!("git path ignored: {error}"))
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn git_path_is_ignored_reports_inspection_failures() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	fs::write(root.join("release.txt"), "release\n")
		.unwrap_or_else(|error| panic!("write release file: {error}"));

	let error = git_path_is_ignored(root, Path::new("release.txt"))
		.await
		.err()
		.unwrap_or_else(|| panic!("expected ignored inspection failure"));
	assert!(
		error
			.to_string()
			.contains("failed to inspect ignored git path release.txt")
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn git_path_is_ignored_reports_command_failures() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path().to_path_buf();
	drop(tempdir);

	let error = git_path_is_ignored(&root, Path::new("release.txt"))
		.await
		.err()
		.unwrap_or_else(|| panic!("expected ignored command failure"));
	assert!(
		error
			.to_string()
			.contains("failed to inspect ignored git path release.txt")
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn run_git_capture_includes_stderr_for_failed_commands() {
	let tempdir = tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
	let root = tempdir.path();
	init_git_repo(root);

	let error = run_git_capture(root, &["show", "missing-commit"], "capture failure")
		.await
		.err()
		.unwrap_or_else(|| panic!("expected failed git capture"));
	assert!(error.to_string().contains("capture failure"));
	assert!(error.to_string().contains("missing-commit"));
}

#[tokio::test(flavor = "multi_thread")]
async fn run_git_process_reports_nonzero_exit_status_details() {
	let mut command = ProcessCommand::new("git");
	command.arg("definitely-not-a-real-git-command");

	let error = run_git_process(command, "process failure")
		.await
		.err()
		.unwrap_or_else(|| panic!("expected failed git process"));
	assert!(error.to_string().contains("process failure"));
	assert!(
		error
			.to_string()
			.contains("definitely-not-a-real-git-command")
	);
}

#[tokio::test(flavor = "multi_thread")]
async fn run_git_process_with_stdin_allows_commands_without_piped_stdin() {
	let mut command = ProcessCommand::new("git");
	command.arg("--version");

	run_git_process_with_stdin(command, b"message", "stdin process")
		.await
		.unwrap_or_else(|error| panic!("stdin process should succeed: {error}"));
}

#[tokio::test(flavor = "multi_thread")]
async fn run_git_process_with_stdin_reports_spawn_failures() {
	let command = ProcessCommand::new("definitely-not-a-real-monochange-test-command");

	let error = run_git_process_with_stdin(command, b"message", "stdin process failure")
		.await
		.err()
		.unwrap_or_else(|| panic!("expected failed stdin git process"));
	assert!(error.to_string().contains("stdin process failure"));
}

#[tokio::test(flavor = "multi_thread")]
async fn run_git_process_with_stdin_reports_nonzero_exit_status_details() {
	let mut command = ProcessCommand::new("git");
	command
		.arg("definitely-not-a-real-git-command")
		.stdin(Stdio::piped());

	let error = run_git_process_with_stdin(command, b"message", "stdin process failure")
		.await
		.err()
		.unwrap_or_else(|| panic!("expected failed stdin git process"));
	assert!(error.to_string().contains("stdin process failure"));
	assert!(
		error
			.to_string()
			.contains("definitely-not-a-real-git-command")
	);
}