git-gamble 2.14.2

blend TDD + TCR to make sure to develop the right thing 😌, baby step by baby step πŸ‘ΆπŸ¦Ά
Documentation
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;

#[cfg(feature = "with_subcommand_hook")]
use crate::git_gamble::subcommand_hook::hook::Hook;
use crate::message::Message;

use super::gamble_result::GambleError;
use super::gamble_result::GambleResult;
use super::git_is_required::git_is_required;

enum CommandError {
	GambleError(GambleError),
	CommandNotFound(Message),
}

pub(crate) struct Repository {
	path: PathBuf,
	dry_run: bool,
	no_verify: bool,
}

impl Repository {
	pub(crate) fn new(path: &Path, dry_run: bool, no_verify: bool) -> Repository {
		Repository {
			path: path.to_path_buf(),
			dry_run,
			no_verify,
		}
	}

	fn path(&self) -> &Path {
		Path::new(&self.path)
	}

	fn executed_command(&self, program: &str, args: &[&str]) -> String {
		format!("`{program} {}` in {}", args.join(" "), self.path.display())
	}
}

/// commands
impl Repository {
	fn shell_command(&self, program: &str, args: &[&str]) -> Result<(), CommandError> {
		let executed_command = self.executed_command(program, args);

		if self.dry_run {
			log::info!("Would execute {executed_command}");

			return Ok(());
		}

		let command = Command::new(program)
			.current_dir(self.path())
			.args(args)
			.status();

		command
			.map_err(|error| {
				CommandError::CommandNotFound(Message::Error(format!(
					"Failed to execute {executed_command}: {error}"
				)))
			})
			.and_then(|status| match status.code() {
				Some(code) => {
					log::info!("{executed_command} exited with {code}");

					if status.success() {
						Ok(())
					} else {
						Err(CommandError::GambleError(GambleError {
							messages: vec![],
							code,
						}))
					}
				}
				None => Err(CommandError::GambleError(GambleError {
					messages: vec![Message::Error(format!(
						"{executed_command} has exited without a normal code"
					))],
					code: 1,
				})),
			})
	}

	pub(crate) fn command(&self, args: &[&str]) -> GambleResult<()> {
		self.shell_command("git", args)
			.map_err(|error| match error {
				CommandError::CommandNotFound(message) => git_is_required().add_message(message),
				CommandError::GambleError(gamble_error) => gamble_error,
			})
			.map_err(|error| {
				let executed_command =
					&format!("`git {}` in {}", args.join(" "), self.path().display());
				let help_message = format!(
					"Failed to execute {executed_command} returns code {}",
					error.code
				);

				error.add_message(Message::Error(help_message))
			})
	}

	fn allow_unknown_hook_name(&self) -> GambleResult<Option<&str>> {
		self.query(&["hook", "run", "--help"]).map(|output| {
			output
				.contains("allow-unknown-hook-name")
				.then_some("--allow-unknown-hook-name")
		})
	}

	pub(crate) fn run_hook(&self, hook_name: &str, args: &[&str]) -> GambleResult<()> {
		if !cfg!(feature = "with_custom_hooks") {
			return Ok(());
		}

		if self.no_verify {
			log::info!("Would execute {hook_name} hook"); // TODO: test this

			return Ok(());
		}

		self.allow_unknown_hook_name()
			.and_then(|allow_unknown_hook_name| {
				let hook_with_arguments = [
					vec!["hook", "run", "--ignore-missing"],
					allow_unknown_hook_name
						.map(|opt| vec![opt])
						.unwrap_or_default(),
					vec![hook_name, "--"],
					args.to_vec(),
				]
				.concat();
				self.command(&hook_with_arguments).map_err(|error| {
					error
						.add_message(Message::Error(format!(
							"The {hook_name} hook returns code {}, but it should be 0",
							error.code
						)))
						.add_message(Message::Info(format!(
							"You can test the hook by executing : git {}",
							hook_with_arguments.join(" "),
						)))
				})
			})
	}

	pub(crate) fn run_test_command(
		&self,
		test_command: Vec<String>,
	) -> Result<std::process::ExitStatus, GambleError> {
		let command = Command::new(&test_command[0])
			.args(&test_command[1..])
			.status();

		command.map_err(|error| {
			let executed_test_command = test_command.join(" ");
			GambleError {
				messages: vec![
					Message::Error(format!("`{executed_test_command}`: {error}")),
					Message::Error(format!(
						"Test command `{executed_test_command}` in {} has exited without a normal code",
						self.path().display()
					)),
				],
				code: 1,
			}
		})
	}
}

/// queries
impl Repository {
	fn query(&self, args: &[&str]) -> GambleResult<String> {
		let command = Command::new("git")
			.current_dir(self.path())
			.args(args)
			.output();

		command
			.map_err(|error| git_is_required().add_message(Message::Error(format!("{error}"))))
			.and_then(|output| {
				String::from_utf8(output.stdout).map_err(|error| GambleError {
					messages: vec![Message::Error(format!("Can't parse git output : {error}"))],
					code: 1,
				})
			})
			.map_err(|error| {
				let executed_command = self.executed_command("git", args);
				error.add_message(Message::Error(format!(
					"Failed to execute {executed_command}"
				)))
			})
	}

	fn get_commit_hash_for(&self, revision: &str) -> GambleResult<String> {
		self.query(&["rev-parse", revision])
	}

	pub(crate) fn head_is_failing_ref(&self) -> bool {
		let head_commit_hash = self.get_commit_hash_for("HEAD");
		let ref_is_failing_commit_hash = self.get_commit_hash_for("gamble-is-failing");

		match (head_commit_hash, ref_is_failing_commit_hash) {
			(Ok(head_commit_hash), Ok(ref_is_failing_commit_hash)) => {
				head_commit_hash == ref_is_failing_commit_hash
			}
			_ => false,
		}
	}

	pub(crate) fn is_clean(&self) -> GambleResult<bool> {
		self.query(&["status", "-z"])
			.map(|status| status.is_empty())
	}
}

/// managing hooks
#[cfg(feature = "with_subcommand_hook")]
impl Repository {
	pub(crate) fn enable_hook(&self, hook: &Hook) -> GambleResult<()> {
		let hook_name = hook.hook_name();
		let hooks_map = hook.hooks();

		hooks_map.iter().try_for_each(|(hook_event, hook_content)| {
			let git_config_section = format!("hook.{}-{}", hook_name, hook_event);

			self.add_hook(
				format!("{}.{}.sh", hook_event, hook_name).as_str(),
				hook_content,
			)
			.and_then(|hook_path| {
				let config_value = hook_path
					.to_str()
					.expect("Unhandled error ; please report it"); // don't know how to test it ; given path are partly checked (directory are created just before) and partly hardcoded (hook filename)

				self.config_set(format!("{git_config_section}.command"), config_value)
			})
			.and_then(|()| self.config_set(format!("{git_config_section}.event"), hook_event))
		})
	}

	pub(crate) fn disable_hook(&self, hook: &Hook) -> GambleResult<()> {
		let hook_name = hook.hook_name();
		let hooks_map = hook.hooks();

		hooks_map.keys().try_for_each(|hook_event| {
			let git_config_section = format!("hook.{}-{}", hook_name, hook_event);

			self.config_unset(format!("{git_config_section}.event"))
				.and_then(|()| self.config_unset(format!("{git_config_section}.command")))
		})
	}

	fn add_hook(&self, hook_file_name: &str, hook_content: &str) -> GambleResult<PathBuf> {
		use std::fs;

		let hooks_path = self.hooks_path();
		let hook_path = hooks_path.join(hook_file_name);

		fs::create_dir_all(hooks_path)
			.and_then(|()| fs::write(&hook_path, hook_content))
			.and_then(|()| {
				#[cfg(unix)]
				{
					use std::fs::set_permissions;
					use std::os::unix::fs::PermissionsExt;

					let owner_all_permissions_all_executable = PermissionsExt::from_mode(0o755);
					set_permissions(&hook_path, owner_all_permissions_all_executable)?
				}
				Ok(())
			})
			.map_err(|error| GambleError {
				messages: vec![
					Message::Error(format!(
						"Failed to create the hook file in {}",
						hook_path.display()
					)),
					Message::Error(format!("{error}")),
					Message::Info(
						"Please make sure you are able to write in that path".to_string(),
					),
				],
				code: 1,
			})
			.map(|()| hook_path)
	}

	fn hooks_path(&self) -> PathBuf {
		let default_hooks_path = self.path().join(".git/hooks");

		self.query(&[
			"config",
			"get",
			"--default",
			default_hooks_path.to_str().unwrap(),
			"core.hooksPath",
		])
		.map(|hooks_path| Path::new(&hooks_path.trim()).to_path_buf())
		.unwrap_or(default_hooks_path)
	}

	fn config_set(&self, config_key: String, config_value: &str) -> Result<(), GambleError> {
		let comment = "Generated by git-gamble";

		self.command(&[
			"config",
			"set",
			"--comment",
			comment,
			config_key.as_str(),
			config_value,
		])
	}

	fn config_unset(&self, config_key: String) -> GambleResult<()> {
		match self.command(&["config", "get", config_key.as_str()]) {
			Ok(()) => self.command(&["config", "unset", config_key.as_str()]),
			Err(_config_is_not_set_do_nothing) => Ok(()),
		}
	}
}