diff --git a/Cargo.lock b/Cargo.lock index 5301ea1..d8d890f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,7 @@ dependencies = [ "tracing", "tracing-human-layer", "tracing-subscriber", + "unindent", "utf8-command", "walkdir", "which", @@ -1218,6 +1219,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + [[package]] name = "utf8-command" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 6fd4733..b5fa20f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ toml = "0.8.19" tracing = { version = "0.1.40", features = ["attributes"] } tracing-human-layer = "0.1.3" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "registry"] } +unindent = "0.2.3" utf8-command = "1.0.1" walkdir = "2.5.0" which = "6.0.3" diff --git a/config.toml b/config.toml index 9b48c92..28adf29 100644 --- a/config.toml +++ b/config.toml @@ -48,3 +48,13 @@ copy_untracked = true # # See: https://cli.github.com/ enable_gh = false + +# Commands to run when a new worktree is added. +commands = [ + # "direnv allow", + # { sh = ''' + # if [ -e flake.nix ]; then + # nix develop --command true + # fi + # ''' }, +] diff --git a/src/add.rs b/src/add.rs index fbde1d6..26e66a0 100644 --- a/src/add.rs +++ b/src/add.rs @@ -11,6 +11,7 @@ use miette::Context; use miette::IntoDiagnostic; use owo_colors::OwoColorize; use owo_colors::Stream; +use owo_colors::Style; use tap::Tap; use tracing::instrument; @@ -175,6 +176,31 @@ impl<'a> WorktreePlan<'a> { command.status_checked()?; self.copy_untracked()?; } + self.run_commands()?; + Ok(()) + } + + #[instrument(level = "trace")] + fn run_commands(&self) -> miette::Result<()> { + for command in self.git.config.file.commands() { + let mut command = command.as_command(); + let command_display = Utf8ProgramAndArgs::from(&command); + tracing::info!( + "{} {command_display}", + '$'.if_supports_color(Stream::Stdout, |text| Style::new() + .cyan() + .bold() + .style(text)) + ); + let status = command + .current_dir(&self.destination) + .status_checked() + .into_diagnostic(); + if let Err(err) = status { + tracing::error!("{err}"); + } + } + Ok(()) } } diff --git a/src/config.rs b/src/config.rs index 76f7ffc..d38f704 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,12 @@ +use std::process::Command; + use camino::Utf8PathBuf; use clap::Parser; use miette::Context; use miette::IntoDiagnostic; +use serde::de::Error; use serde::Deserialize; +use unindent::unindent; use xdg::BaseDirectories; use crate::cli::Cli; @@ -78,6 +82,9 @@ pub struct ConfigFile { #[serde(default)] enable_gh: Option, + + #[serde(default)] + commands: Vec, } impl ConfigFile { @@ -106,6 +113,65 @@ impl ConfigFile { pub fn enable_gh(&self) -> bool { self.enable_gh.unwrap_or(false) } + + pub fn commands(&self) -> Vec { + self.commands.clone() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum ShellCommand { + Simple(ShellArgs), + Shell { sh: String }, +} + +impl ShellCommand { + pub fn as_command(&self) -> Command { + match self { + ShellCommand::Simple(args) => { + let mut command = Command::new(&args.program); + command.args(&args.args); + command + } + ShellCommand::Shell { sh } => { + let mut command = Command::new("sh"); + let sh = unindent(sh); + command.args(["-c", sh.trim_ascii()]); + command + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellArgs { + program: String, + args: Vec, +} + +impl<'de> Deserialize<'de> for ShellArgs { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let quoted: String = Deserialize::deserialize(deserializer)?; + let mut args = shell_words::split("ed).map_err(D::Error::custom)?; + + if args.is_empty() { + return Err(D::Error::invalid_value( + serde::de::Unexpected::Str("ed), + // TODO: This error message doesn't actually get propagated upward + // correctly, so you get "data did not match any variant of untagged enum + // ShellCommand" instead. + &"a shell command (you are missing a program)", + )); + } + + let program = args.remove(0); + + Ok(Self { program, args }) + } } #[cfg(test)] @@ -122,6 +188,7 @@ mod tests { default_branches: vec!["main".to_owned(), "master".to_owned(), "trunk".to_owned(),], copy_untracked: Some(true), enable_gh: Some(false), + commands: vec![], } ); } diff --git a/tests/config_commands.rs b/tests/config_commands.rs new file mode 100644 index 0000000..036f6bb --- /dev/null +++ b/tests/config_commands.rs @@ -0,0 +1,43 @@ +use command_error::CommandExt; +use expect_test::expect; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn config_commands() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_worktree_repo("my-repo")?; + + prole.write_config( + r#" + commands = [ + "sh -c 'echo Puppy wuz here > puppy-log'", + { sh = ''' + echo 2wice the Pupyluv >> puppy-log + ''' }, + ] + "#, + )?; + + prole + .cd_cmd("my-repo") + .args(["add", "puppy"]) + .status_checked()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy").file( + "puppy-log", + expect![[r#" + Puppy wuz here + 2wice the Pupyluv + "#]], + ), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/config_commands_default.rs b/tests/config_commands_default.rs new file mode 100644 index 0000000..89d5e68 --- /dev/null +++ b/tests/config_commands_default.rs @@ -0,0 +1,26 @@ +use command_error::CommandExt; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn config_commands_default() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_worktree_repo("my-repo")?; + + prole + .cd_cmd("my-repo") + .args(["add", "puppy"]) + .status_checked()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + // Wow girl give us nothing! + WorktreeState::new("puppy").branch("puppy").status([]), + ]) + .assert(); + + Ok(()) +}