From 6cff6f829f3ddb7139bdef88b1918ed438d7e18e Mon Sep 17 00:00:00 2001 From: elkowar Date: Sun, 19 Jan 2025 20:04:13 +0100 Subject: [PATCH 1/8] feat: run canonicalization for git through git filters --- Cargo.lock | 7 ++ Cargo.toml | 1 + src/git_filter_server.rs | 251 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 56 ++++++++- src/yolk.rs | 30 +++++ 6 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 src/git_filter_server.rs diff --git a/Cargo.lock b/Cargo.lock index febb580..15dfecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,6 +605,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home" version = "0.5.11" @@ -2008,6 +2014,7 @@ dependencies = [ "extend", "fs-err", "glob", + "hex", "indoc", "insta", "maplit", diff --git a/Cargo.toml b/Cargo.toml index 294b02e..600695e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ winnow = { version = "0.6.20", features = ["unstable-recover"] } cov-mark = "2.0.0" arbitrary = { version = "1.4.1", features = ["derive"] } symlink = "0.1.0" +hex = "0.4.3" # rhai-autodocs = { version = "0.7.0", path = "../../clones/rhai-autodocs" } [dev-dependencies] diff --git a/src/git_filter_server.rs b/src/git_filter_server.rs new file mode 100644 index 0000000..74e30c9 --- /dev/null +++ b/src/git_filter_server.rs @@ -0,0 +1,251 @@ +use std::str::FromStr; + +use miette::{Context, IntoDiagnostic}; +use proto::{GitWriter, PacketKind}; + +pub struct GitFilterServer { + processor: P, + input: R, + output: GitWriter, +} + +#[derive(Debug, Clone)] +pub enum GitFilterMode { + Clean, + Smudge, +} + +impl FromStr for GitFilterMode { + type Err = miette::Report; + + fn from_str(s: &str) -> Result { + match s { + "clean" => Ok(GitFilterMode::Clean), + "smudge" => Ok(GitFilterMode::Smudge), + _ => Err(miette::miette!("Unknown git filter mode: {}", s)), + } + } +} + +pub trait GitFilterProcessor { + fn process(&mut self, path: &str, mode: GitFilterMode, input: String) + -> miette::Result; +} + +impl GitFilterServer { + pub fn new(input: R, output: W, processor: P) -> Self { + Self { + processor, + input, + output: GitWriter(output), + } + } + + pub fn run(&mut self) -> miette::Result<()> { + self.handle_handshake()?; + loop { + let Some((command, pathname)) = self.read_command_header()? else { + return Ok(()); + }; + match command.as_str() { + t @ "clean" | t @ "smudge" => { + let mode = GitFilterMode::from_str(t)?; + let content = read_bin_until_flush(&mut self.input) + .context("Failed to read content from git")?; + let content_str = String::from_utf8(content).into_diagnostic()?; + match self.processor.process(&pathname, mode, content_str) { + Ok(success) => self.send_processing_success(success)?, + Err(error) => { + eprintln!("Error in git filter: {error:?}"); + self.output.write_all(b"status=error")?; + self.output.send_flush()?; + } + } + } + _ => { + miette::bail!("Unknown command: {}", command); + } + } + } + } + + fn send_processing_success(&mut self, success: String) -> Result<(), miette::Error> { + self.output + .write_all(b"status=success") + .context("failed to send status=success")?; + self.output.send_flush()?; + self.output + .write_all(success.as_bytes()) + .context("Failed to write processing output")?; + self.output.send_flush()?; + self.output.send_flush()?; + Ok(()) + } + + fn expect_text_packet(&mut self, expected: &str) -> miette::Result<()> { + if read_text_packet(&mut self.input) + .with_context(|| format!("Expected text packet: {}", expected))? + .map_or(false, |x| x != expected) + { + miette::bail!("Expected text packet: {}", expected); + } + Ok(()) + } + + fn handle_handshake(&mut self) -> miette::Result<()> { + self.expect_text_packet("git-filter-client")?; + self.expect_text_packet("version=2")?; + if proto::read_packet(&mut self.input)? != Some(PacketKind::Flush) { + miette::bail!("Expected flush after client hello"); + }; + + self.output.write_all(b"git-filter-server")?; + self.output.write_all(b"version=2")?; + self.output.flush()?; + self.output.send_flush()?; + + let mut filter = false; + let mut smudge = false; + while let Some(command) = read_text_packet(&mut self.input)? { + match command.as_str() { + "capability=clean" => filter = true, + "capability=smudge" => smudge = true, + _ => {} + } + } + if filter { + self.output.write_all(b"capability=clean")?; + } + if smudge { + self.output.write_all(b"capability=smudge")?; + } + self.output.send_flush()?; + Ok(()) + } + + fn read_command_header(&mut self) -> miette::Result> { + let mut command = None; + let mut pathname = None; + let mut got_something = false; + while let Some(input) = + read_text_packet(&mut self.input).context("failed to start reading new file data")? + { + got_something = true; + if let Some(input_command) = input.strip_prefix("command=") { + command = Some(input_command.to_string()); + } else if let Some(input_pathname) = input.strip_prefix("pathname=") { + pathname = Some(input_pathname.to_string()); + } + } + if !got_something { + return Ok(None); + } + match (command, pathname) { + (Some(command), Some(pathname)) => Ok(Some((command, pathname))), + (None, _) => miette::bail!("Missing command"), + (_, None) => miette::bail!("Missing pathname"), + } + } +} + +fn read_bin_packet(read: &mut impl std::io::Read) -> miette::Result>> { + match proto::read_packet(read)? { + Some(PacketKind::Data(x)) => Ok(Some(x)), + Some(PacketKind::Flush) => Ok(None), + None => Ok(None), + } +} + +fn read_bin_until_flush(read: &mut impl std::io::Read) -> miette::Result> { + let mut result = Vec::new(); + while let Some(bin) = proto::read_packet(read).context("Failed to read packet")? { + match bin { + PacketKind::Data(x) => result.extend(x), + PacketKind::Flush => return Ok(result), + } + } + Ok(result) +} + +fn read_text_packet(read: &mut impl std::io::Read) -> miette::Result> { + let Some(bin) = read_bin_packet(read).context("Failed to read binary text data")? else { + return Ok(None); + }; + + if !bin.ends_with(&[b'\n']) { + miette::bail!("Expected text packet to end with a newline"); + } + Ok(Some( + String::from_utf8(bin[..bin.len() - 1].to_vec()).into_diagnostic()?, + )) +} + +mod proto { + use miette::{Context, IntoDiagnostic, Result}; + + pub const MAX_PACKET_LEN: usize = 65516; + + #[derive(Clone, Debug, Eq, PartialEq)] + pub enum PacketKind { + Data(Vec), + Flush, + } + + pub struct GitWriter(pub T); + + impl GitWriter { + pub fn write_all(&mut self, buf: &[u8]) -> miette::Result<()> { + for chunk in buf.chunks(MAX_PACKET_LEN - 4) { + let len_bytes = (chunk.len() as u16 + 4).to_be_bytes(); + let mut len_hex = [0; 4]; + hex::encode_to_slice(&len_bytes, &mut len_hex).unwrap(); + self.0.write_all(&len_hex).into_diagnostic()?; + self.0.write_all(chunk).into_diagnostic()?; + } + Ok(()) + } + + pub(super) fn flush(&mut self) -> miette::Result<()> { + self.0.flush().into_diagnostic() + } + pub(super) fn send_flush(&mut self) -> miette::Result<()> { + self.0 + .write_all(b"0000") + .into_diagnostic() + .context("Failed to send flush packet")?; + self.0 + .flush() + .into_diagnostic() + .context("Failed to flush after sending flush packet") + } + } + + pub fn read_packet(read: &mut impl std::io::Read) -> Result> { + let mut len_hex = [0; 4]; + match read.read_exact(&mut len_hex) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + other => other + .into_diagnostic() + .wrap_err("Failed to read packet length")?, + } + let mut len = [0; 2]; + hex::decode_to_slice(&len_hex, &mut len) + .into_diagnostic() + .wrap_err("Bad hex length received")?; + let len = u16::from_be_bytes(len) as usize; + if len == 0 { + return Ok(Some(PacketKind::Flush)); + } + let len = len - 4; + if len > MAX_PACKET_LEN { + miette::bail!("Packet too long: {}", len); + } else if len == 0 { + miette::bail!("Packet size must never be 0"); + } + let mut result = Vec::with_capacity(len); + result.resize(len, 0); + read.read_exact(&mut result[..]).into_diagnostic()?; + Ok(Some(PacketKind::Data(result))) + } +} diff --git a/src/lib.rs b/src/lib.rs index 27b5c73..58da81e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod doc_generator; pub mod eggs_config; +pub mod git_filter_server; pub mod multi_error; pub mod script; pub mod templating; diff --git a/src/main.rs b/src/main.rs index 406943e..ff296e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,11 @@ use tracing_subscriber::{ filter, fmt::format::FmtSpan, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer, }; -use yolk::util::PathExt; -use yolk::yolk::{EvalMode, Yolk}; +use yolk::{ + git_filter_server::{self, GitFilterMode}, + yolk::{EvalMode, Yolk}, +}; +use yolk::{script::eval_ctx::EvalCtx, util::PathExt}; #[derive(clap::Parser, Debug)] #[command(version, about)] @@ -96,7 +99,9 @@ enum Command { List, /// Open your `yolk.rhai` or the given egg in your `$EDITOR` of choice. - Edit { egg: Option }, + Edit { + egg: Option, + }, /// Watch for changes in your templated files and re-sync them when they change. Watch { @@ -107,9 +112,13 @@ enum Command { no_sync: bool, }, + GitFilter, + #[cfg(feature = "docgen")] #[command(hide(true))] - Docs { dir: PathBuf }, + Docs { + dir: PathBuf, + }, } pub(crate) fn main() -> Result<()> { @@ -382,6 +391,18 @@ fn run_command(args: Args) -> Result<()> { std::thread::sleep(std::time::Duration::from_secs(1)); } } + Command::GitFilter => { + let mut server = git_filter_server::GitFilterServer::new( + std::io::stdin(), + std::io::stdout(), + GitFilterProcessor { + yolk: &yolk, + eval_ctx: None, + }, + ); + server.run()?; + } + #[cfg(feature = "docgen")] Command::Docs { dir } => { let docs = doc_generator::generate_docs(yolk)?; @@ -392,3 +413,30 @@ fn run_command(args: Args) -> Result<()> { } Ok(()) } + +struct GitFilterProcessor<'a> { + yolk: &'a Yolk, + eval_ctx: Option, +} +impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { + fn process( + &mut self, + pathname: &str, + mode: git_filter_server::GitFilterMode, + input: String, + ) -> Result { + let mut eval_ctx = if let Some(eval_ctx) = &mut self.eval_ctx { + eval_ctx + } else { + let eval_ctx = self.yolk.prepare_eval_ctx_for_templates(match mode { + GitFilterMode::Clean => EvalMode::Canonical, + GitFilterMode::Smudge => EvalMode::Local, + })?; + self.eval_ctx = Some(eval_ctx); + self.eval_ctx.as_mut().unwrap() + }; + // TODO: Figure out what happens on failure, and make sure that it doesn't end up comitting non-cleaned stuff. + let evaluated = self.yolk.eval_template(&mut eval_ctx, &pathname, &input)?; + Ok(evaluated) + } +} diff --git a/src/yolk.rs b/src/yolk.rs index ab53d29..6bf771e 100644 --- a/src/yolk.rs +++ b/src/yolk.rs @@ -27,6 +27,36 @@ impl Yolk { pub fn init_yolk(&self) -> Result<()> { self.yolk_paths.create()?; + std::process::Command::new("git") + .arg("init") + .current_dir(self.yolk_paths.root_path()) + .status() + .into_diagnostic()?; + std::process::Command::new("git") + .args(&[ + "config", + "filter.yolk.clean", + &format!("yolk git-filter clean",), + ]) + .current_dir(self.yolk_paths.root_path()) + .status() + .into_diagnostic()?; + std::process::Command::new("git") + .args(&[ + "config", + "filter.yolk.smudge", + &format!("yolk git-filter smudge",), + ]) + .current_dir(self.yolk_paths.root_path()) + .status() + .into_diagnostic()?; + fs_err::write( + self.yolk_paths.root_path().join(".gitattributes"), + "* filter=yolk", + ) + .into_diagnostic() + .context("Failed to configure yolk filter in .gitattributes")?; + Ok(()) } From 7965045bb82dbf5293774d8a16a3e17ee6b1efac Mon Sep 17 00:00:00 2001 From: elkowar Date: Tue, 21 Jan 2025 21:19:29 +0100 Subject: [PATCH 2/8] Implement properly checking for which files should be templated --- src/eggs_config.rs | 2 ++ src/main.rs | 36 ++++++++++++++++++++++++++++++++---- src/yolk.rs | 2 ++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/eggs_config.rs b/src/eggs_config.rs index 17d93d0..4132b5e 100644 --- a/src/eggs_config.rs +++ b/src/eggs_config.rs @@ -136,6 +136,8 @@ impl EggConfig { .collect() } + /// Expand the glob patterns in the `templates` field to a list of paths. + /// The globbed paths are considered relative to `in_dir`. The resulting list of paths will contain absolute paths. pub fn templates_globexpanded(&self, in_dir: impl AsRef) -> miette::Result> { let in_dir = in_dir.as_ref(); let mut paths = Vec::new(); diff --git a/src/main.rs b/src/main.rs index ff296e0..4cd4f4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -395,10 +395,7 @@ fn run_command(args: Args) -> Result<()> { let mut server = git_filter_server::GitFilterServer::new( std::io::stdin(), std::io::stdout(), - GitFilterProcessor { - yolk: &yolk, - eval_ctx: None, - }, + GitFilterProcessor::new(&yolk), ); server.run()?; } @@ -417,7 +414,18 @@ fn run_command(args: Args) -> Result<()> { struct GitFilterProcessor<'a> { yolk: &'a Yolk, eval_ctx: Option, + templated_files: Option>, +} +impl<'a> GitFilterProcessor<'a> { + pub fn new(yolk: &'a Yolk) -> Self { + Self { + yolk, + eval_ctx: None, + templated_files: None, + } + } } + impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { fn process( &mut self, @@ -425,6 +433,7 @@ impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { mode: git_filter_server::GitFilterMode, input: String, ) -> Result { + // TODO: Fix the fact that this assumes we will only ever be called with one mode at a time let mut eval_ctx = if let Some(eval_ctx) = &mut self.eval_ctx { eval_ctx } else { @@ -435,6 +444,25 @@ impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { self.eval_ctx = Some(eval_ctx); self.eval_ctx.as_mut().unwrap() }; + + let templated_files = if let Some(ref templated_files) = self.templated_files { + templated_files + } else { + let egg_configs = self.yolk.load_egg_configs(eval_ctx)?; + let result = egg_configs + .iter() + .map(|(name, config)| { + config.templates_globexpanded(self.yolk.paths().egg_path(name)) + }) + .collect::>>>()?; + self.templated_files = Some(result.into_iter().flatten().collect()); + self.templated_files.as_ref().unwrap() + }; + + if !templated_files.contains(&self.yolk.paths().root_path().join(pathname)) { + return Ok(input); + } + // TODO: Figure out what happens on failure, and make sure that it doesn't end up comitting non-cleaned stuff. let evaluated = self.yolk.eval_template(&mut eval_ctx, &pathname, &input)?; Ok(evaluated) diff --git a/src/yolk.rs b/src/yolk.rs index 6bf771e..ffef39c 100644 --- a/src/yolk.rs +++ b/src/yolk.rs @@ -239,6 +239,8 @@ impl Yolk { /// fetch the `eggs` variable from a given EvalCtx. pub fn load_egg_configs(&self, eval_ctx: &mut EvalCtx) -> Result> { + // TODO: Important: We need to somehow verify that the template file list is ALWAYS the same between canonical and local mode. + // let eggs_map = eval_ctx .yolk_file_module() .expect("Tried to load egg configs before loading yolk file. This is a bug.") From 979ec1104ffadea29ecfcc57839eaed847d198d4 Mon Sep 17 00:00:00 2001 From: elkowar Date: Tue, 21 Jan 2025 21:30:17 +0100 Subject: [PATCH 3/8] Various small steps for git-filter logic Allow re-initializing git stuff Remove safe-guarding Ensure that yolk init moves old .yolk_git dirs to .git Add .deployed_cache to gitignore --- docs/src/getting_started.md | 10 ++---- docs/src/git_concepts.md | 22 ++---------- src/git_filter_server.rs | 2 +- src/main.rs | 34 +++++++----------- src/yolk.rs | 70 +++++++++++++++++++++++++++---------- src/yolk_paths.rs | 46 ++++-------------------- 6 files changed, 77 insertions(+), 107 deletions(-) diff --git a/docs/src/getting_started.md b/docs/src/getting_started.md index 46af45b..46e5390 100644 --- a/docs/src/getting_started.md +++ b/docs/src/getting_started.md @@ -58,19 +58,15 @@ back to the alacritty egg directory `~/.config/yolk/eggs/alacritty`. ### Committing your dots to git Now, we want to make sure our dotfiles are in version control and pushed to our git host of choice. -Every interaction with git should be done through the `yolk git` command. -This ensures that git sees the canonical (stable) representation of your files, and automatically performs them from within the yolk directory. +Yolk sets up a git-filter to ensure that git sees the canonical (stable) representation of your files. +For convenience, you can use the `yolk git` command to interact with your git repository from anywhere on your system. ```bash -$ yolk git init -$ yolk safeguard $ yolk git add --all $ yolk git commit -m "Setup alacritty" ``` -To understand what `yolk safeguard` does, see [safeguarding git](./git_concepts.md#safeguarding-git). - -You can now set up your git reomte and use git as usual -- just remember to always use `yolk git`, especially when you're committing your files. +You can now set up your git reomte and use git as usual. ### Baby's first template diff --git a/docs/src/git_concepts.md b/docs/src/git_concepts.md index 2eeb5ec..0454ef0 100644 --- a/docs/src/git_concepts.md +++ b/docs/src/git_concepts.md @@ -2,24 +2,10 @@ Basic familiarity with git is assumed. -## Safeguarding git +## How yolk works with git -Yolk wraps the git CLI to ensure that git only ever interacts with your dotfiles in their canonical state. -If it didn't do that, you would end up committing the local state of your dotfiles, -which would conflict with their state from another machine -- which is what yolk is trying to solve. - -To ensure that you're not accidentally using the regular git CLI for your dotfiles, it is recommended to "safeguard" your dotfiles' git directory. -To do this, simply run - -```bash -$ yolk safeguard -``` - -after cloning or initializing your dotfiles. - -This simply renames the `.git` directory to `.yolk_git`, which means the regular git CLI won't see the repository anymore. -You are now more or less forced to use the `yolk git` command instead -- which conveniently doesn't just ensure consistency of the git state, -but also works from anywhere in your filesystem! +An important part of how yolk works is that your dotfiles will always be committed in a "canonical" state, no matter from what system. +Yolk achieves this by setting up a git filter that will automatically transform all templated files into their canonical variants whenever git is reading them. ## Cloning your dotfiles @@ -27,7 +13,6 @@ To clone your dotfiles on a new machine, simply clone the repository to `.config ```bash $ git clone "$XDG_CONFIG_HOME/yolk" -$ yolk safeguard ``` After that, you can start `yolk sync`ing your eggs! @@ -42,4 +27,3 @@ So, instead of - `git commit -m "cool changes"`, you run `yolk git commit -m "cool changes`, and so on. -This ensures the files are always in the correct canonical state, and makes it possible to interact with a safeguarded git repository. diff --git a/src/git_filter_server.rs b/src/git_filter_server.rs index 74e30c9..f176d40 100644 --- a/src/git_filter_server.rs +++ b/src/git_filter_server.rs @@ -9,7 +9,7 @@ pub struct GitFilterServer { output: GitWriter, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Eq, PartialEq)] pub enum GitFilterMode { Clean, Smudge, diff --git a/src/main.rs b/src/main.rs index 4cd4f4f..ad7e951 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,8 +49,6 @@ enum Command { Status, /// Make sure you don't accidentally commit your local egg states /// - /// This renames `.git` to `.yolk_git` to ensure that git interaction happens through the yolk CLI - Safeguard, /// Evaluate a rhai expression. /// /// The expression is executed in the same scope that template tag expression are evaluated in. @@ -180,15 +178,11 @@ fn run_command(args: Args) -> Result<()> { let yolk = Yolk::new(yolk_paths); match &args.command { Command::Init => yolk.init_yolk()?, - Command::Safeguard => yolk.paths().safeguard_git_dir()?, Command::Status => { // TODO: Add a verification that exactly all the eggs in the eggs dir are defined in the // yolk.rhai file. yolk.paths().check()?; - if yolk.paths().active_yolk_git_dir()? == yolk.paths().yolk_default_git_path() { - println!("Yolk git is not safeguarded. It is recommended to run `yolk safeguard`."); - } yolk.with_canonical_state(|| { yolk.paths() .start_git_command_builder()? @@ -415,6 +409,7 @@ struct GitFilterProcessor<'a> { yolk: &'a Yolk, eval_ctx: Option, templated_files: Option>, + mode: GitFilterMode, } impl<'a> GitFilterProcessor<'a> { pub fn new(yolk: &'a Yolk) -> Self { @@ -422,6 +417,7 @@ impl<'a> GitFilterProcessor<'a> { yolk, eval_ctx: None, templated_files: None, + mode: GitFilterMode::Clean, } } } @@ -433,21 +429,16 @@ impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { mode: git_filter_server::GitFilterMode, input: String, ) -> Result { - // TODO: Fix the fact that this assumes we will only ever be called with one mode at a time - let mut eval_ctx = if let Some(eval_ctx) = &mut self.eval_ctx { - eval_ctx - } else { + if self.mode != mode || self.eval_ctx.is_none() { let eval_ctx = self.yolk.prepare_eval_ctx_for_templates(match mode { GitFilterMode::Clean => EvalMode::Canonical, GitFilterMode::Smudge => EvalMode::Local, })?; self.eval_ctx = Some(eval_ctx); - self.eval_ctx.as_mut().unwrap() - }; + } + let mut eval_ctx = self.eval_ctx.as_mut().unwrap(); - let templated_files = if let Some(ref templated_files) = self.templated_files { - templated_files - } else { + if self.templated_files.is_none() { let egg_configs = self.yolk.load_egg_configs(eval_ctx)?; let result = egg_configs .iter() @@ -456,15 +447,16 @@ impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { }) .collect::>>>()?; self.templated_files = Some(result.into_iter().flatten().collect()); - self.templated_files.as_ref().unwrap() - }; - - if !templated_files.contains(&self.yolk.paths().root_path().join(pathname)) { + } + let templated_files = self.templated_files.as_ref().unwrap(); + let canonical_file_path = self.yolk.paths().root_path().join(&pathname).canonical()?; + if !templated_files.contains(&canonical_file_path) { return Ok(input); } - // TODO: Figure out what happens on failure, and make sure that it doesn't end up comitting non-cleaned stuff. - let evaluated = self.yolk.eval_template(&mut eval_ctx, &pathname, &input)?; + let evaluated = + self.yolk + .eval_template(&mut eval_ctx, &canonical_file_path.abbr(), &input)?; Ok(evaluated) } } diff --git a/src/yolk.rs b/src/yolk.rs index ffef39c..c08f00f 100644 --- a/src/yolk.rs +++ b/src/yolk.rs @@ -2,6 +2,7 @@ use fs_err::PathExt as _; use miette::miette; use miette::{Context, IntoDiagnostic, Result, Severity}; use normalize_path::NormalizePath; +use std::io::Write; use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -26,37 +27,68 @@ impl Yolk { } pub fn init_yolk(&self) -> Result<()> { - self.yolk_paths.create()?; - std::process::Command::new("git") - .arg("init") - .current_dir(self.yolk_paths.root_path()) - .status() - .into_diagnostic()?; + if !self.yolk_paths.root_path().exists() { + self.yolk_paths.create()?; + } + if !self.yolk_paths.root_path().join(".git").exists() { + if self.yolk_paths.root_path().join(".yolk_git").exists() { + fs_err::rename( + self.yolk_paths.root_path().join(".yolk_git"), + self.yolk_paths.root_path().join(".git"), + ) + .into_diagnostic()?; + } else { + std::process::Command::new("git") + .arg("init") + .current_dir(self.yolk_paths.root_path()) + .status() + .into_diagnostic()?; + } + } + self.init_git_config()?; + + Ok(()) + } + + pub fn init_git_config(&self) -> Result<()> { std::process::Command::new("git") .args(&[ "config", - "filter.yolk.clean", - &format!("yolk git-filter clean",), + "filter.yolk.process", + &format!( + "yolk --yolk-dir {} --home-dir {} git-filter", + self.yolk_paths.root_path().canonical()?.display(), + self.yolk_paths.home_path().canonical()?.display() + ), ]) .current_dir(self.yolk_paths.root_path()) .status() .into_diagnostic()?; std::process::Command::new("git") - .args(&[ - "config", - "filter.yolk.smudge", - &format!("yolk git-filter smudge",), - ]) + .args(&["config", "filter.yolk.required", "true"]) .current_dir(self.yolk_paths.root_path()) .status() .into_diagnostic()?; - fs_err::write( - self.yolk_paths.root_path().join(".gitattributes"), - "* filter=yolk", - ) - .into_diagnostic() - .context("Failed to configure yolk filter in .gitattributes")?; + 'gitattrs: { + let git_attrs_path = self.yolk_paths.root_path().join(".gitattributes"); + if PathBuf::from(&git_attrs_path).exists() { + if fs_err::read_to_string(&git_attrs_path) + .into_diagnostic()? + .contains("* filter=yolk") + { + break 'gitattrs; + } + } + fs_err::OpenOptions::new() + .create(true) + .append(true) + .open(git_attrs_path) + .into_diagnostic()? + .write_fmt(format_args!("* filter=yolk")) + .into_diagnostic() + .context("Failed to configure yolk filter in .gitattributes")?; + } Ok(()) } diff --git a/src/yolk_paths.rs b/src/yolk_paths.rs index 2b18413..9a8e392 100644 --- a/src/yolk_paths.rs +++ b/src/yolk_paths.rs @@ -4,10 +4,7 @@ use fs_err::PathExt; use miette::{Context as _, IntoDiagnostic, Result}; use normalize_path::NormalizePath; -use crate::{ - eggs_config::EggConfig, - util::{self, PathExt as _}, -}; +use crate::{eggs_config::EggConfig, util::PathExt as _}; const DEFAULT_YOLK_RHAI: &str = indoc::indoc! {r#" export let data = #{ @@ -21,7 +18,8 @@ const DEFAULT_YOLK_RHAI: &str = indoc::indoc! {r#" const DEFAULT_GITIGNORE: &str = indoc::indoc! {r#" # Ignore the yolk git directory - /.yolk_git + /.git + /.deployed_cache "#}; pub struct YolkPaths { @@ -110,22 +108,6 @@ impl YolkPaths { Ok(()) } - /// Safeguard git directory by renaming it to `.yolk_git` - pub fn safeguard_git_dir(&self) -> Result<()> { - if self.root_path().join(".git").exists() { - miette::ensure!( - !self.yolk_safeguarded_git_path().exists(), - help = "Safeguarded Yolk renames .git to .yolk_git to ensure you don't accidentally commit without yolks processing", - "Yolk directory contains both a .git directory and a .yolk_git directory" - ); - util::rename_safely( - self.root_path().join(".git"), - self.yolk_safeguarded_git_path(), - )?; - } - Ok(()) - } - /// Start an invocation of the `git` command with the `--git-dir` and `--work-tree` set to the yolk git and root path. pub fn start_git_command_builder(&self) -> Result { let mut cmd = std::process::Command::new("git"); @@ -146,22 +128,16 @@ impl YolkPaths { pub fn yolk_default_git_path(&self) -> PathBuf { self.root_path.join(".git") } - pub fn yolk_safeguarded_git_path(&self) -> PathBuf { - self.root_path.join(".yolk_git") - } /// Return the path to the active git directory, /// which is either the [`yolk_default_git_path`] (`.git`) or the [`yolk_safeguarded_git_path`] (`.yolk_git`) if it exists. pub fn active_yolk_git_dir(&self) -> Result { let default_git_dir = self.yolk_default_git_path(); - let safeguarded_git_dir = self.yolk_safeguarded_git_path(); - if safeguarded_git_dir.exists() { - Ok(safeguarded_git_dir) - } else if default_git_dir.exists() { + if default_git_dir.exists() { Ok(default_git_dir) } else { miette::bail!( - help = "Run `git init`, then try again!", + help = "Run `yolk init`, then try again!", "No git directory initialized" ) } @@ -391,7 +367,7 @@ mod test { TempDir, }; use miette::IntoDiagnostic; - use predicates::{path::exists, prelude::PredicateBooleanExt}; + use predicates::path::exists; use test_log::test; use crate::eggs_config::EggConfig; @@ -478,16 +454,6 @@ mod test { Ok(()) } - #[test] - pub fn test_safeguard() -> TestResult { - let (home, yolk, _) = setup_and_init_test_yolk()?; - home.child("yolk/.git").create_dir_all()?; - yolk.paths().safeguard_git_dir()?; - home.child("yolk/.git").assert(exists().not()); - home.child("yolk/.yolk_git").assert(exists()); - Ok(()) - } - #[test] pub fn test_default_script() -> TestResult { let root = TempDir::new().into_diagnostic()?; From 9e4248bc02cb7cffeec96e57199ae50a70695543 Mon Sep 17 00:00:00 2001 From: elkowar Date: Sat, 25 Jan 2025 18:22:28 +0100 Subject: [PATCH 4/8] test: Add integration tests for git-filter command --- Cargo.lock | 27 +++++++++++ Cargo.toml | 1 + src/git_tests.rs | 124 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 2 +- src/util.rs | 3 +- src/yolk.rs | 12 +++-- 7 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 src/git_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 15dfecb..1bb91a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,22 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "assert_cmd" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "assert_fs" version = "1.1.2" @@ -171,6 +187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786a307d683a5bf92e6fd5fd69a7eb613751668d1d8d67d802846dfe367c62c8" dependencies = [ "memchr", + "regex-automata 0.4.9", "serde", ] @@ -1668,6 +1685,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2004,6 +2030,7 @@ name = "yolk_dots" version = "0.1.0" dependencies = [ "arbitrary", + "assert_cmd", "assert_fs", "cached", "clap", diff --git a/Cargo.toml b/Cargo.toml index 600695e..5d5d8de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ test-log = { version = "0.2.16", default-features = false, features = [ "color", "trace", ] } +assert_cmd = "2.0.16" [profile.dev.package] insta = { opt-level = 3 } diff --git a/src/git_tests.rs b/src/git_tests.rs new file mode 100644 index 0000000..93a2393 --- /dev/null +++ b/src/git_tests.rs @@ -0,0 +1,124 @@ +use assert_cmd::Command; +use assert_fs::{ + assert::PathAssert as _, + prelude::{FileWriteStr as _, PathChild}, +}; + +use crate::{ + util::test_util::{setup_and_init_test_yolk, TestResult}, + yolk::{EvalMode, Yolk}, +}; + +struct TestEnv { + pub home: assert_fs::TempDir, + pub eggs: assert_fs::fixture::ChildPath, + pub yolk: Yolk, +} + +impl TestEnv { + pub fn init() -> miette::Result { + let (home, yolk, eggs) = setup_and_init_test_yolk()?; + Ok(Self { home, yolk, eggs }) + } + pub fn yolk_root(&self) -> assert_fs::fixture::ChildPath { + self.home.child("yolk") + } + + pub fn start_git_command(&self) -> Command { + let mut cmd = Command::new("git"); + cmd.env("HOME", self.home.path()) + .current_dir(self.yolk_root().path()); + cmd + } + + pub fn git_add_all(&self) -> assert_cmd::assert::Assert { + self.start_git_command().args(&["add", "--all"]).assert() + } + pub fn git_show_staged(&self, path: impl ToString) -> assert_cmd::assert::Assert { + self.start_git_command() + .args(&["show", &format!(":{}", path.to_string())]) + .assert() + } +} + +#[test] +fn test_init_works() -> TestResult { + let env = TestEnv::init()?; + let yolk_binary_path = assert_cmd::cargo::cargo_bin("yolk"); + env.start_git_command() + .args(&["config", "--local", "--get-all", "filter.yolk.process"]) + .assert() + .success() + .stdout(format!( + "{} --yolk-dir {} --home-dir {} git-filter\n", + yolk_binary_path.display(), + env.yolk_root().path().display(), + env.home.path().display(), + )); + Ok(()) +} + +#[test] +fn test_git_add() -> TestResult { + let env = TestEnv::init()?; + + env.home + .child("yolk/yolk.rhai") + .write_str(indoc::indoc! {r#" + export let eggs = #{ + foo: #{ targets: `~/foo`, strategy: "put", templates: ["file"]}, + bar: #{ targets: `~/bar`, strategy: "put", templates: ["file"]}, + }; + "#})?; + env.eggs + .child("foo/file") + .write_str(r#"foo=1 # {< replace_value(LOCAL.to_string())>}"#)?; + env.eggs.child("foo/non-template").write_str(r#"{<1+1>}"#)?; + env.eggs + .child("bar/file") + .write_str(r#"# foo # {}"#)?; + env.yolk.sync_to_mode(EvalMode::Local)?; + env.eggs + .child("foo/file") + .assert("foo=true # {< replace_value(LOCAL.to_string())>}"); + env.eggs.child("bar/file").assert("foo # {}"); + env.eggs.child("foo/non-template").assert(r#"{<1+1>}"#); + + env.git_add_all().success(); + env.git_show_staged("eggs/foo/file") + .stdout("foo=false # {< replace_value(LOCAL.to_string())>}"); + env.git_show_staged("eggs/bar/file") + .stdout("# foo # {}"); + env.git_show_staged("eggs/foo/non-template") + .stdout("{<1+1>}"); + Ok(()) +} + +#[test] +fn test_git_add_with_error() -> TestResult { + let env = TestEnv::init()?; + + env.home + .child("yolk/yolk.rhai") + .write_str(indoc::indoc! {r#" + export let eggs = #{ + foo: #{ targets: `~/foo`, strategy: "put", templates: ["bad"]}, + bar: #{ targets: `~/bar`, strategy: "put", templates: ["file"]}, + }; + "#})?; + env.eggs + .child("foo/bad") + .write_str(r#"foo=1 # {< bad syntax >}"#)?; + env.eggs + .child("bar/file") + .write_str(r#"# foo # {}"#)?; + env.eggs.child("foo/bad").assert("foo=1 # {< bad syntax >}"); + env.eggs + .child("bar/file") + .assert("# foo # {}"); + + env.git_add_all().failure(); + env.git_show_staged("eggs/foo/bar").failure(); + env.git_show_staged("eggs/bar/file").failure(); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 58da81e..1c4e7ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,8 @@ mod doc_generator; pub mod eggs_config; pub mod git_filter_server; +#[cfg(test)] +pub mod git_tests; pub mod multi_error; pub mod script; pub mod templating; diff --git a/src/main.rs b/src/main.rs index ad7e951..008c8fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -177,7 +177,7 @@ fn run_command(args: Args) -> Result<()> { let yolk = Yolk::new(yolk_paths); match &args.command { - Command::Init => yolk.init_yolk()?, + Command::Init => yolk.init_yolk("yolk")?, Command::Status => { // TODO: Add a verification that exactly all the eggs in the eggs dir are defined in the // yolk.rhai file. diff --git a/src/util.rs b/src/util.rs index d35421e..296ab4a 100644 --- a/src/util.rs +++ b/src/util.rs @@ -165,7 +165,8 @@ pub mod test_util { set_home_dir(home.to_path_buf()); let eggs = home.child("yolk/eggs"); - yolk.init_yolk()?; + let yolk_binary_path = assert_cmd::cargo::cargo_bin("yolk"); + yolk.init_yolk(yolk_binary_path.to_string_lossy().as_ref())?; Ok((home, yolk, eggs)) } diff --git a/src/yolk.rs b/src/yolk.rs index c08f00f..e330a87 100644 --- a/src/yolk.rs +++ b/src/yolk.rs @@ -26,7 +26,11 @@ impl Yolk { Self { yolk_paths } } - pub fn init_yolk(&self) -> Result<()> { + /// Init or update the yolk directory, setting up the required git structure and files. + /// + /// The yolk binary path is used in the git filter configuration. + /// Most of the time, it should just be `yolk`, but for tests we provide a specific path here. + pub fn init_yolk(&self, yolk_binary: &str) -> Result<()> { if !self.yolk_paths.root_path().exists() { self.yolk_paths.create()?; } @@ -45,18 +49,18 @@ impl Yolk { .into_diagnostic()?; } } - self.init_git_config()?; + self.init_git_config(yolk_binary)?; Ok(()) } - pub fn init_git_config(&self) -> Result<()> { + pub fn init_git_config(&self, yolk_binary: &str) -> Result<()> { std::process::Command::new("git") .args(&[ "config", "filter.yolk.process", &format!( - "yolk --yolk-dir {} --home-dir {} git-filter", + "{yolk_binary} --yolk-dir {} --home-dir {} git-filter", self.yolk_paths.root_path().canonical()?.display(), self.yolk_paths.home_path().canonical()?.display() ), From 5eb40d55c2a1e4ba196348564f4986b356055b8b Mon Sep 17 00:00:00 2001 From: elkowar Date: Sat, 25 Jan 2025 18:49:12 +0100 Subject: [PATCH 5/8] Build before running tests --- .github/workflows/check.yml | 4 ++- src/git_filter_server.rs | 5 +++ src/git_tests.rs | 6 ++-- src/main.rs | 62 ++++++++++--------------------------- src/util.rs | 2 +- src/yolk.rs | 20 ++++++------ src/yolk_paths.rs | 20 ++---------- 7 files changed, 43 insertions(+), 76 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0b52437..230afd2 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -52,4 +52,6 @@ jobs: - name: Cargo check run: cargo check - name: Run tests - run: cargo nextest run + run: | + cargo build + cargo nextest run diff --git a/src/git_filter_server.rs b/src/git_filter_server.rs index f176d40..e0309cc 100644 --- a/src/git_filter_server.rs +++ b/src/git_filter_server.rs @@ -1,3 +1,8 @@ +//! Logic to handle the [git long-running filter process](https://git-scm.com/docs/gitattributes#_long_running_filter_process) connection. +//! +//! This is used to implement the `yolk git-filter` command, which gets called by git whenever the user checks out or checks in some files. +//! This allows doing the template processing (canonicalization) in-memory within git, rather than having to change the files on-disk before and after interacting with them through git. + use std::str::FromStr; use miette::{Context, IntoDiagnostic}; diff --git a/src/git_tests.rs b/src/git_tests.rs index 93a2393..fca69ae 100644 --- a/src/git_tests.rs +++ b/src/git_tests.rs @@ -10,9 +10,9 @@ use crate::{ }; struct TestEnv { - pub home: assert_fs::TempDir, - pub eggs: assert_fs::fixture::ChildPath, - pub yolk: Yolk, + home: assert_fs::TempDir, + eggs: assert_fs::fixture::ChildPath, + yolk: Yolk, } impl TestEnv { diff --git a/src/main.rs b/src/main.rs index 008c8fe..983367a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -70,15 +70,9 @@ enum Command { canonical: bool, }, - /// Run a git-command within the yolk directory while in canonical state. - /// - /// Some git commands do not get executed in the canonical state, such as `git push`. + /// Run a git-command within the yolk directory. #[clap(alias = "g")] Git { - /// Run the command in canonical state, even if it typically is not necesary. - #[arg(long)] - force_canonical: bool, - #[clap(allow_hyphen_values = true)] command: Vec, }, @@ -97,9 +91,7 @@ enum Command { List, /// Open your `yolk.rhai` or the given egg in your `$EDITOR` of choice. - Edit { - egg: Option, - }, + Edit { egg: Option }, /// Watch for changes in your templated files and re-sync them when they change. Watch { @@ -110,13 +102,12 @@ enum Command { no_sync: bool, }, + #[command(hide(true))] GitFilter, #[cfg(feature = "docgen")] #[command(hide(true))] - Docs { - dir: PathBuf, - }, + Docs { dir: PathBuf }, } pub(crate) fn main() -> Result<()> { @@ -177,19 +168,18 @@ fn run_command(args: Args) -> Result<()> { let yolk = Yolk::new(yolk_paths); match &args.command { - Command::Init => yolk.init_yolk("yolk")?, + Command::Init => yolk.init_yolk(None)?, Command::Status => { // TODO: Add a verification that exactly all the eggs in the eggs dir are defined in the // yolk.rhai file. + yolk.init_git_config(None)?; yolk.paths().check()?; - yolk.with_canonical_state(|| { - yolk.paths() - .start_git_command_builder()? - .args(["status", "--short"]) - .status() - .into_diagnostic() - })?; + yolk.paths() + .start_git_command_builder() + .args(["status", "--short"]) + .status() + .into_diagnostic()?; } Command::List => { let mut eggs = yolk.list_eggs()?; @@ -223,30 +213,12 @@ fn run_command(args: Args) -> Result<()> { .map_err(|e| e.into_report("", expr))?; println!("{result}"); } - Command::Git { - command, - force_canonical, - } => { - let mut cmd = yolk.paths().start_git_command_builder()?; - cmd.args(command); - // if the command is `git push`, we don't need to enter canonical state - // before executing it - - let first_cmd = command.first().map(|x| x.as_ref()); - if !force_canonical - && (first_cmd == Some("push") || first_cmd == Some("init") || first_cmd.is_none()) - { - cmd.status().into_diagnostic()?; - } else { - // TODO: Ensure that, in something goes wrong during the sync, the git command is _not_ run. - // Even if, normally, the sync call would only emit warnings, we must _never_ commit a failed syc. - // This also means there should potentially be slightly more separation between syncing templates and deployment, - // as deployment errors are not fatal for git usage. - yolk.with_canonical_state(|| { - cmd.status().into_diagnostic()?; - Ok(()) - })?; - } + Command::Git { command } => { + yolk.paths() + .start_git_command_builder() + .args(command) + .status() + .into_diagnostic()?; } Command::EvalTemplate { path, canonical } => { let text = match path { diff --git a/src/util.rs b/src/util.rs index 296ab4a..1448225 100644 --- a/src/util.rs +++ b/src/util.rs @@ -166,7 +166,7 @@ pub mod test_util { let eggs = home.child("yolk/eggs"); let yolk_binary_path = assert_cmd::cargo::cargo_bin("yolk"); - yolk.init_yolk(yolk_binary_path.to_string_lossy().as_ref())?; + yolk.init_yolk(Some(yolk_binary_path.to_string_lossy().as_ref()))?; Ok((home, yolk, eggs)) } diff --git a/src/yolk.rs b/src/yolk.rs index e330a87..5bb2f9e 100644 --- a/src/yolk.rs +++ b/src/yolk.rs @@ -28,9 +28,10 @@ impl Yolk { /// Init or update the yolk directory, setting up the required git structure and files. /// - /// The yolk binary path is used in the git filter configuration. - /// Most of the time, it should just be `yolk`, but for tests we provide a specific path here. - pub fn init_yolk(&self, yolk_binary: &str) -> Result<()> { + /// `yolk_binary` is used as the path that git-filter uses when calling yolk to process the files. + /// In most cases, the `yolk_binary` can be left to None. + /// However, for tests, it should explicitly be provided to ensure that the correct yolk binary is being used. + pub fn init_yolk(&self, yolk_binary: Option<&str>) -> Result<()> { if !self.yolk_paths.root_path().exists() { self.yolk_paths.create()?; } @@ -54,8 +55,10 @@ impl Yolk { Ok(()) } - pub fn init_git_config(&self, yolk_binary: &str) -> Result<()> { - std::process::Command::new("git") + pub fn init_git_config(&self, yolk_binary: Option<&str>) -> Result<()> { + let yolk_binary = yolk_binary.unwrap_or("yolk"); + self.paths() + .start_git_command_builder() .args(&[ "config", "filter.yolk.process", @@ -65,12 +68,11 @@ impl Yolk { self.yolk_paths.home_path().canonical()?.display() ), ]) - .current_dir(self.yolk_paths.root_path()) .status() .into_diagnostic()?; - std::process::Command::new("git") + self.paths() + .start_git_command_builder() .args(&["config", "filter.yolk.required", "true"]) - .current_dir(self.yolk_paths.root_path()) .status() .into_diagnostic()?; @@ -89,7 +91,7 @@ impl Yolk { .append(true) .open(git_attrs_path) .into_diagnostic()? - .write_fmt(format_args!("* filter=yolk")) + .write_fmt(format_args!("\n* filter=yolk\n")) .into_diagnostic() .context("Failed to configure yolk filter in .gitattributes")?; } diff --git a/src/yolk_paths.rs b/src/yolk_paths.rs index 9a8e392..7940e4a 100644 --- a/src/yolk_paths.rs +++ b/src/yolk_paths.rs @@ -109,15 +109,15 @@ impl YolkPaths { } /// Start an invocation of the `git` command with the `--git-dir` and `--work-tree` set to the yolk git and root path. - pub fn start_git_command_builder(&self) -> Result { + pub fn start_git_command_builder(&self) -> std::process::Command { let mut cmd = std::process::Command::new("git"); cmd.current_dir(self.root_path()).args([ "--git-dir", - &self.active_yolk_git_dir()?.to_string_lossy(), + &self.yolk_default_git_path().to_string_lossy(), "--work-tree", &self.root_path().to_string_lossy(), ]); - Ok(cmd) + cmd } pub fn root_path(&self) -> &std::path::Path { &self.root_path @@ -129,20 +129,6 @@ impl YolkPaths { self.root_path.join(".git") } - /// Return the path to the active git directory, - /// which is either the [`yolk_default_git_path`] (`.git`) or the [`yolk_safeguarded_git_path`] (`.yolk_git`) if it exists. - pub fn active_yolk_git_dir(&self) -> Result { - let default_git_dir = self.yolk_default_git_path(); - if default_git_dir.exists() { - Ok(default_git_dir) - } else { - miette::bail!( - help = "Run `yolk init`, then try again!", - "No git directory initialized" - ) - } - } - /// /// Path to the `yolk.rhai` file pub fn yolk_rhai_path(&self) -> PathBuf { self.root_path.join("yolk.rhai") From 9c54eda6f0a2d298fcbd4171a3f665ca4f233761 Mon Sep 17 00:00:00 2001 From: elkowar Date: Sun, 26 Jan 2025 17:22:18 +0100 Subject: [PATCH 6/8] always ensure git is properly configured --- docs/src/getting_started.md | 2 +- src/git_filter_server.rs | 11 ++++---- src/git_tests.rs | 6 ++--- src/main.rs | 27 ++++++++++++------- src/util.rs | 40 +++++++++++++++++++++++++-- src/yolk.rs | 54 +++++++++++++++---------------------- src/yolk_paths.rs | 10 +------ 7 files changed, 87 insertions(+), 63 deletions(-) diff --git a/docs/src/getting_started.md b/docs/src/getting_started.md index 46e5390..af0ea89 100644 --- a/docs/src/getting_started.md +++ b/docs/src/getting_started.md @@ -58,7 +58,7 @@ back to the alacritty egg directory `~/.config/yolk/eggs/alacritty`. ### Committing your dots to git Now, we want to make sure our dotfiles are in version control and pushed to our git host of choice. -Yolk sets up a git-filter to ensure that git sees the canonical (stable) representation of your files. +Yolk sets up a [git-filter in your .gitattributes](https://git-scm.com/docs/gitattributes#_long_running_filter_process) to ensure that git sees the canonical (stable) representation of your files. For convenience, you can use the `yolk git` command to interact with your git repository from anywhere on your system. ```bash diff --git a/src/git_filter_server.rs b/src/git_filter_server.rs index e0309cc..7bea1a2 100644 --- a/src/git_filter_server.rs +++ b/src/git_filter_server.rs @@ -90,7 +90,7 @@ impl GitFilterServer fn expect_text_packet(&mut self, expected: &str) -> miette::Result<()> { if read_text_packet(&mut self.input) .with_context(|| format!("Expected text packet: {}", expected))? - .map_or(false, |x| x != expected) + .is_some_and(|x| x != expected) { miette::bail!("Expected text packet: {}", expected); } @@ -177,7 +177,7 @@ fn read_text_packet(read: &mut impl std::io::Read) -> miette::Result assert_cmd::assert::Assert { - self.start_git_command().args(&["add", "--all"]).assert() + self.start_git_command().args(["add", "--all"]).assert() } pub fn git_show_staged(&self, path: impl ToString) -> assert_cmd::assert::Assert { self.start_git_command() - .args(&["show", &format!(":{}", path.to_string())]) + .args(["show", &format!(":{}", path.to_string())]) .assert() } } @@ -46,7 +46,7 @@ fn test_init_works() -> TestResult { let env = TestEnv::init()?; let yolk_binary_path = assert_cmd::cargo::cargo_bin("yolk"); env.start_git_command() - .args(&["config", "--local", "--get-all", "filter.yolk.process"]) + .args(["config", "--local", "--get-all", "filter.yolk.process"]) .assert() .success() .stdout(format!( diff --git a/src/main.rs b/src/main.rs index 983367a..f809459 100644 --- a/src/main.rs +++ b/src/main.rs @@ -199,10 +199,16 @@ fn run_command(args: Args) -> Result<()> { ); } } - Command::Sync { canonical } => yolk.sync_to_mode(match *canonical { - true => EvalMode::Canonical, - false => EvalMode::Local, - })?, + Command::Sync { canonical } => { + // Lets always ensure that the yolk dir is in a properly set up state. + // This should later be replaced with some sort of version-aware compatibility check. + yolk.init_yolk(None)?; + + yolk.sync_to_mode(match *canonical { + true => EvalMode::Canonical, + false => EvalMode::Local, + })? + } Command::Eval { expr, canonical } => { let mut eval_ctx = yolk.prepare_eval_ctx_for_templates(match *canonical { true => EvalMode::Canonical, @@ -214,6 +220,7 @@ fn run_command(args: Args) -> Result<()> { println!("{result}"); } Command::Git { command } => { + yolk.init_yolk(None)?; yolk.paths() .start_git_command_builder() .args(command) @@ -394,7 +401,7 @@ impl<'a> GitFilterProcessor<'a> { } } -impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { +impl git_filter_server::GitFilterProcessor for GitFilterProcessor<'_> { fn process( &mut self, pathname: &str, @@ -408,7 +415,7 @@ impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { })?; self.eval_ctx = Some(eval_ctx); } - let mut eval_ctx = self.eval_ctx.as_mut().unwrap(); + let eval_ctx = self.eval_ctx.as_mut().unwrap(); if self.templated_files.is_none() { let egg_configs = self.yolk.load_egg_configs(eval_ctx)?; @@ -421,14 +428,14 @@ impl<'a> git_filter_server::GitFilterProcessor for GitFilterProcessor<'a> { self.templated_files = Some(result.into_iter().flatten().collect()); } let templated_files = self.templated_files.as_ref().unwrap(); - let canonical_file_path = self.yolk.paths().root_path().join(&pathname).canonical()?; + let canonical_file_path = self.yolk.paths().root_path().join(pathname).canonical()?; if !templated_files.contains(&canonical_file_path) { return Ok(input); } - let evaluated = - self.yolk - .eval_template(&mut eval_ctx, &canonical_file_path.abbr(), &input)?; + let evaluated = self + .yolk + .eval_template(eval_ctx, &canonical_file_path.abbr(), &input)?; Ok(evaluated) } } diff --git a/src/util.rs b/src/util.rs index 1448225..27bf965 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,6 +1,11 @@ -use std::path::{Path, PathBuf}; +use std::{ + collections::HashSet, + io::Write, + path::{Path, PathBuf}, +}; use cached::UnboundCache; +use fs_err::OpenOptions; use miette::{Context as _, IntoDiagnostic as _}; use regex::Regex; @@ -59,6 +64,37 @@ pub fn remove_symlink(path: impl AsRef) -> miette::Result<()> { Ok(()) } +/// Ensure that a file contains the given lines, appending them if they are missing. If the file does not yet exist, it will be created. +pub fn ensure_file_contains_lines(path: impl AsRef, lines: &[&str]) -> miette::Result<()> { + let path = path.as_ref(); + + let mut trailing_newline_exists = true; + + let existing_lines = if path.exists() { + let content = fs_err::read_to_string(path).into_diagnostic()?; + trailing_newline_exists = content.ends_with('\n'); + content.lines().map(|x| x.to_string()).collect() + } else { + HashSet::new() + }; + if lines.iter().all(|x| existing_lines.contains(*x)) { + return Ok(()); + } + let mut file = OpenOptions::new() + .append(true) + .create(true) + .open(path) + .into_diagnostic()?; + let missing_lines = lines.iter().filter(|x| !existing_lines.contains(**x)); + if !trailing_newline_exists { + writeln!(file).into_diagnostic()?; + } + for line in missing_lines { + writeln!(file, "{}", line).into_diagnostic()?; + } + Ok(()) +} + #[extend::ext(pub)] impl Path { /// [`fs_err::canonicalize`] but on windows it doesn't return UNC paths. @@ -121,7 +157,7 @@ pub mod test_util { use miette::IntoDiagnostic as _; thread_local! { - static HOME_DIR: RefCell> = RefCell::new(None); + static HOME_DIR: RefCell> = const { RefCell::new(None) }; } pub fn set_home_dir(path: PathBuf) { diff --git a/src/yolk.rs b/src/yolk.rs index 5bb2f9e..0d8c7bf 100644 --- a/src/yolk.rs +++ b/src/yolk.rs @@ -2,7 +2,7 @@ use fs_err::PathExt as _; use miette::miette; use miette::{Context, IntoDiagnostic, Result, Severity}; use normalize_path::NormalizePath; -use std::io::Write; + use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -17,6 +17,8 @@ use crate::{ yolk_paths::{Egg, YolkPaths}, }; +const GITIGNORE_ENTRIES: &[&str] = &["/.git", "/.deployed_cache"]; + pub struct Yolk { yolk_paths: YolkPaths, } @@ -55,46 +57,35 @@ impl Yolk { Ok(()) } + #[tracing::instrument(skip_all, fields(yolk_dir = self.yolk_paths.root_path().abbr()))] pub fn init_git_config(&self, yolk_binary: Option<&str>) -> Result<()> { - let yolk_binary = yolk_binary.unwrap_or("yolk"); + tracing::trace!("Ensuring that git config is properly set up"); + util::ensure_file_contains_lines( + self.paths().root_path().join(".gitignore"), + GITIGNORE_ENTRIES, + ) + .context("Failed to ensure .gitignore is configured correctly")?; + + let yolk_process_cmd = &format!( + "{} --yolk-dir {} --home-dir {} git-filter", + yolk_binary.unwrap_or("yolk"), + self.yolk_paths.root_path().canonical()?.display(), + self.yolk_paths.home_path().canonical()?.display() + ); self.paths() .start_git_command_builder() - .args(&[ - "config", - "filter.yolk.process", - &format!( - "{yolk_binary} --yolk-dir {} --home-dir {} git-filter", - self.yolk_paths.root_path().canonical()?.display(), - self.yolk_paths.home_path().canonical()?.display() - ), - ]) + .args(["config", "filter.yolk.process", yolk_process_cmd]) .status() .into_diagnostic()?; self.paths() .start_git_command_builder() - .args(&["config", "filter.yolk.required", "true"]) + .args(["config", "filter.yolk.required", "true"]) .status() .into_diagnostic()?; - 'gitattrs: { - let git_attrs_path = self.yolk_paths.root_path().join(".gitattributes"); - if PathBuf::from(&git_attrs_path).exists() { - if fs_err::read_to_string(&git_attrs_path) - .into_diagnostic()? - .contains("* filter=yolk") - { - break 'gitattrs; - } - } - fs_err::OpenOptions::new() - .create(true) - .append(true) - .open(git_attrs_path) - .into_diagnostic()? - .write_fmt(format_args!("\n* filter=yolk\n")) - .into_diagnostic() - .context("Failed to configure yolk filter in .gitattributes")?; - } + let git_attrs_path = self.yolk_paths.root_path().join(".gitattributes"); + util::ensure_file_contains_lines(git_attrs_path, &["* filter=yolk"]) + .context("Failed to ensure .gitattributes file is configured correctly")?; Ok(()) } @@ -397,7 +388,6 @@ impl Yolk { /// /// First syncs them to canonical then runs the closure, then syncs them back to local. pub fn with_canonical_state(&self, f: impl FnOnce() -> Result) -> Result { - // TODO: Consider using a pre_commit and post_commit hook instead of doing all this stuff. tracing::info!("Converting all templates into their canonical state"); self.sync_to_mode(EvalMode::Canonical)?; let result = f(); diff --git a/src/yolk_paths.rs b/src/yolk_paths.rs index 7940e4a..2580f81 100644 --- a/src/yolk_paths.rs +++ b/src/yolk_paths.rs @@ -16,12 +16,6 @@ const DEFAULT_YOLK_RHAI: &str = indoc::indoc! {r#" }; "#}; -const DEFAULT_GITIGNORE: &str = indoc::indoc! {r#" - # Ignore the yolk git directory - /.git - /.deployed_cache -"#}; - pub struct YolkPaths { /// Path to the yolk directory. root_path: PathBuf, @@ -102,7 +96,6 @@ impl YolkPaths { } fs_err::create_dir_all(path).into_diagnostic()?; fs_err::create_dir_all(self.eggs_dir_path()).into_diagnostic()?; - fs_err::write(self.root_path().join(".gitignore"), DEFAULT_GITIGNORE).into_diagnostic()?; fs_err::write(self.yolk_rhai_path(), DEFAULT_YOLK_RHAI).into_diagnostic()?; Ok(()) @@ -358,7 +351,7 @@ mod test { use crate::eggs_config::EggConfig; - use super::{YolkPaths, DEFAULT_GITIGNORE}; + use super::YolkPaths; #[test] pub fn test_setup() { @@ -369,7 +362,6 @@ mod test { assert!(yolk_paths.check().is_ok()); root.child("yolk/eggs").assert(exists()); root.child("yolk/yolk.rhai").assert(DEFAULT_YOLK_RHAI); - root.child("yolk/.gitignore").assert(DEFAULT_GITIGNORE); } #[test] From 9ab3148ccaa07876168d57d6b7c607c04958f985 Mon Sep 17 00:00:00 2001 From: elkowar Date: Sun, 26 Jan 2025 18:09:49 +0100 Subject: [PATCH 7/8] docs: Add docs on splitting yolk.rhai into multiple files --- docs/src/yolk_rhai.md | 32 ++++++++++++++++++++++++++++++++ src/script/stdlib.rs | 7 +++++++ 2 files changed, 39 insertions(+) diff --git a/docs/src/yolk_rhai.md b/docs/src/yolk_rhai.md index 8d52273..f352536 100644 --- a/docs/src/yolk_rhai.md +++ b/docs/src/yolk_rhai.md @@ -102,3 +102,35 @@ To look at the contents of those variables or try out your logic, you can always ```bash $ yolk eval 'print(SYSTEM)' ``` + +## Splitting up into multiple files + +Rhai allows you to import other files into your scripts. +For example, let's say you want to keep your color theme definition in a separate file. +Simply create a new `colors.rhai` file next to your `yolk.rhai`, and make sure to explicitly declare exported variables as `export`: + +```rust +export let gruvbox = #{ + background: "#282828", + foreground: "#ebdbb2", +}; + +fn some_function() { + print("hi") +} +``` + +Note that functions are exported by default. + +Now, in your `yolk.rhai`, import this script, giving the module an explict name: + +```rs +import "colors" as colors; +``` + +Now you can refer to anything exported from that file as `colors::thing`, i.e.: + +```rs +let theme = colors::gruvbox; +colors::some_function(); +``` diff --git a/src/script/stdlib.rs b/src/script/stdlib.rs index 438f8d9..943e16e 100644 --- a/src/script/stdlib.rs +++ b/src/script/stdlib.rs @@ -110,10 +110,17 @@ pub fn utils_module() -> Module { .in_global_namespace() .set_into_module(&mut module, rhai_color_hex_to_rgb); + FuncRegistration::new("color_hex_to_rgb") + .with_comments(["/// Convert a hex color string to an RGB map."]) + .with_params_info(["hex_string: &str", "Result"]) + .in_global_namespace() + .set_into_module(&mut module, rhai_color_hex_to_rgb); + let color_hex_to_rgb_str = |hex_string: String| -> Result> { let (r, g, b, _) = color_hex_to_rgb(&hex_string)?; Ok(format!("rgb({r}, {g}, {b})")) }; + FuncRegistration::new("color_hex_to_rgb_str") .with_comments(["/// Convert a hex color string to an RGB string."]) .with_params_info(["hex_string: &str", "Result"]) From b535eafaefd84f3c679ea5085f64f9c696b565eb Mon Sep 17 00:00:00 2001 From: ElKowar Date: Sun, 26 Jan 2025 18:10:13 +0100 Subject: [PATCH 8/8] Release v0.1.1 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe9a4d..d702801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1](https://github.com/elkowar/yolk/compare/v0.1.0...v0.1.1) - 2025-01-26 + +### Added + +- run canonicalization for git through git filters + ## [0.1.0](https://github.com/elkowar/yolk/compare/v0.0.16...v0.1.0) - 2025-01-06 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1bb91a6..f926ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2027,7 +2027,7 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yolk_dots" -version = "0.1.0" +version = "0.1.1" dependencies = [ "arbitrary", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 5d5d8de..b1cd956 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "yolk_dots" authors = ["ElKowar "] description = "Templated dotfile management without template files" -version = "0.1.0" +version = "0.1.1" edition = "2021" repository = "https://github.com/elkowar/yolk" homepage = "https://elkowar.github.io/yolk"