├── .appveyor.yml ├── .gitignore ├── .travis.yml ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── build.rs ├── src └── lib.rs └── test ├── test.rs └── testdata ├── empty-user-hook └── .cargo-husky │ └── hooks │ └── pre-push └── user-hooks └── .cargo-husky └── hooks ├── post-merge └── pre-commit /.appveyor.yml: -------------------------------------------------------------------------------- 1 | version: "{build}" 2 | build: off 3 | install: 4 | - curl -sSf -o rustup-init.exe https://win.rustup.rs 5 | - rustup-init.exe --default-host x86_64-pc-windows-gnu --default-toolchain stable -y 6 | - set PATH=%PATH%;C:\Users\appveyor\.cargo\bin 7 | - rustc -Vv 8 | - cargo -V 9 | test_script: 10 | - cargo build -vv 11 | - cargo test 12 | deploy: off 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | 5 | /build 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - linux 3 | - osx 4 | language: rust 5 | rust: stable 6 | cache: cargo 7 | before_script: 8 | - rustup component add rustfmt 9 | - rustc -V 10 | - cargo -V 11 | - rustfmt -V 12 | script: 13 | - cargo build -vv 14 | - cargo test 15 | - cargo fmt -- --check 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cargo-husky" 3 | version = "1.5.0" 4 | authors = ["rhysd "] 5 | description = "husky for cargo" 6 | repository = "https://github.com/rhysd/cargo-husky" 7 | homepage = "https://github.com/rhysd/cargo-husky#readme" 8 | readme = "README.md" 9 | keywords = ["git", "hook", "cargo"] 10 | categories = ["development-tools"] 11 | license = "MIT" 12 | build = "build.rs" 13 | include = ["build.rs", "LICENSE.txt", "Cargo.toml", "src/**/*.rs"] 14 | 15 | [package.metadata.release] 16 | no-dev-version = true 17 | 18 | [badges] 19 | travis-ci = { repository = "rhysd/cargo-husky" } 20 | appveyor = { repository = "rhysd/cargo-husky" } 21 | maintenance = { status = "passively-maintained" } 22 | 23 | [[test]] 24 | name = "integration" 25 | path = "test/test.rs" 26 | 27 | [features] 28 | default = ["prepush-hook", "run-cargo-test", "run-for-all"] 29 | prepush-hook = [] 30 | precommit-hook = [] 31 | postmerge-hook = [] 32 | run-cargo-test = [] 33 | run-cargo-check = [] 34 | run-cargo-clippy = [] 35 | run-cargo-fmt = [] 36 | run-for-all = [] 37 | user-hooks = [] 38 | 39 | [dependencies] 40 | 41 | [dev-dependencies] 42 | libc = "0.2.43" 43 | lazy_static = "1.1" 44 | semver = "0.9.0" 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | the MIT License 2 | 3 | Copyright (c) 2018 rhysd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 16 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 17 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR 20 | THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Husky for Cargo :dog: 2 | ===================== 3 | [![Crates.io][crates-io badge]][cargo-husky] 4 | [![Build Status on Linux/macOS][travis-ci badge]][travis-ci] 5 | [![Build status on Windows][appveyor badge]][appveyor] 6 | 7 | [cargo-husky][] is a crate for Rust project managed by [cargo][]. In short, cargo-husky is a Rust 8 | version of [husky][]. 9 | 10 | cargo-husky is a development tool to set Git hooks automatically on `cargo test`. By hooking `pre-push` 11 | and running `cargo test` automatically, it prevents broken codes from being pushed to a remote 12 | repository. 13 | 14 | 15 | ## Usage 16 | 17 | Please add `cargo-husky` crate to `[dev-dependencies]` section of your project's `Cargo.toml`. 18 | 19 | ```toml 20 | [dev-dependencies] 21 | cargo-husky = "1" 22 | ``` 23 | 24 | Then run tests in your project directory. 25 | 26 | ``` 27 | $ cargo test 28 | ``` 29 | 30 | Check Git hook was generated at `.git/hooks/pre-push`. 31 | cargo-husky generates a hook script which runs `cargo test` by default. 32 | 33 | e.g. 34 | 35 | ```bash 36 | #!/bin/sh 37 | # 38 | # This hook was set by cargo-husky v1.0.0: https://github.com/rhysd/cargo-husky#readme 39 | # Generated by script /path/to/cargo-husky/build.rs 40 | # Output at /path/to/target/debug/build/cargo-husky-xxxxxx/out 41 | # 42 | 43 | set -e 44 | 45 | echo '+cargo test' 46 | cargo test 47 | ``` 48 | 49 | Note: cargo-husky does nothing on `cargo test` when 50 | - hook script was already generated by the same version of cargo-husky 51 | - another hook script put by someone else is already there 52 | 53 | To uninstall cargo-husky, please remove `cargo-husky` from your `[dev-dependencies]` and remove 54 | hook scripts from `.git/hooks`. 55 | 56 | [Japanese blogpost](https://rhysd.hatenablog.com/entry/2018/10/08/205041) 57 | 58 | 59 | ## Customize behavior 60 | 61 | Behavior of cargo-husky can be customized by feature flags of `cargo-husky` package. 62 | You can specify them in `[dev-dependencies.cargo-husky]` section of `Cargo.toml` instead of adding 63 | `cargo-husky` to `[dev-dependencies]` section. 64 | 65 | e.g. 66 | 67 | ```toml 68 | [dev-dependencies.cargo-husky] 69 | version = "1" 70 | default-features = false # Disable features which are enabled by default 71 | features = ["precommit-hook", "run-cargo-test", "run-cargo-clippy"] 72 | ``` 73 | 74 | This configuration generates `.git/hooks/pre-commit` script which runs `cargo test` and `cargo clippy`. 75 | 76 | All features are follows: 77 | 78 | | Feature | Description | Default | 79 | |--------------------|---------------------------------------------------------------------|----------| 80 | | `run-for-all` | Add `--all` option to command to run it for all crates in workspace | Enabled | 81 | | `prepush-hook` | Generate `pre-push` hook script | Enabled | 82 | | `precommit-hook` | Generate `pre-commit` hook script | Disabled | 83 | | `postmerge-hook` | Generate `post-merge` hook script | Disabled | 84 | | `run-cargo-test` | Run `cargo test` in hook scripts | Enabled | 85 | | `run-cargo-check` | Run `cargo check` in hook scripts | Disabled | 86 | | `run-cargo-clippy` | Run `cargo clippy -- -D warnings` in hook scripts | Disabled | 87 | | `run-cargo-fmt` | Run `cargo fmt -- --check` in hook scripts | Disabled | 88 | | `user-hooks` | See below section | Disabled | 89 | 90 | 91 | ## User Hooks 92 | 93 | If generated hooks by `run-cargo-test` or `run-cargo-clippy` features are not sufficient for you, 94 | you can create your own hook scripts and tell cargo-husky to put them into `.git/hooks` directory. 95 | 96 | 1. Create `.cargo-husky/hooks` directory at the same directory where `.git` directory is put. 97 | 2. Create hook files such as `pre-push`, `pre-commit`, ... as you like. 98 | 3. Give an executable permission to the files (on \*nix OS). 99 | 4. Write `features = ["user-hooks"]` to `[dev-dependencies.cargo-husky]` section of your `Cargo.toml`. 100 | 5. Check whether it works by removing an existing `target` directory and run `cargo test`. 101 | 102 | e.g. 103 | 104 | ``` 105 | your-repository/ 106 | ├── .git 107 | └── .cargo-husky 108 | └── hooks 109 | ├── post-merge 110 | └── pre-commit 111 | ``` 112 | 113 | ```toml 114 | [dev-dependencies.cargo-husky] 115 | version = "1" 116 | default-features = false 117 | features = ["user-hooks"] 118 | ``` 119 | 120 | cargo-husky inserts an information header to copied hook files in `.git/hooks/` in order to detect 121 | self version update. 122 | 123 | Note that, when `user-hooks` feature is enabled, other all features are disabled. You need to prepare 124 | all hooks in `.cargo-husky/hooks` directory. 125 | 126 | 127 | ## Ignore Installing Hooks 128 | 129 | When you don't want to install hooks for some reason, please set `$CARGO_HUSKY_DONT_INSTALL_HOOKS` 130 | environment variable. 131 | 132 | ``` 133 | CARGO_HUSKY_DONT_INSTALL_HOOKS=true cargo test 134 | ``` 135 | 136 | 137 | ## How It Works 138 | 139 | [husky][] utilizes npm's hook scripts, but cargo does not provide such hooks. 140 | Instead, cargo-husky sets Git hook automatically on running tests by [cargo's build script feature][build scripts]. 141 | 142 | Build scripts are intended to be used for building third-party non-Rust code such as C libraries. 143 | They are automatically run on compiling crates. 144 | 145 | If `cargo-husky` crate is added to `dev-dependencies` section, it is compiled at running tests. 146 | At the timing, [build script](./build.rs) is run and sets Git hook automatically. 147 | The build script find the `.git` directory to put hooks based on `$OUT_DIR` environment variable 148 | which is automatically set by `cargo`. 149 | 150 | cargo-husky puts Git hook file only once for the same version. When it is updated to a new version, 151 | it overwrites the existing hook by detecting itself was updated. 152 | 153 | cargo-husky is developed on macOS and tested on Linux/macOS/Windows with 'stable' channel Rust toolchain. 154 | 155 | ## License 156 | 157 | [MIT](./LICENSE.txt) 158 | 159 | [cargo-husky]: https://crates.io/crates/cargo-husky 160 | [cargo]: https://github.com/rust-lang/cargo 161 | [husky]: https://github.com/typicode/husky 162 | [build scripts]: https://doc.rust-lang.org/cargo/reference/build-scripts.html 163 | [travis-ci badge]: https://travis-ci.org/rhysd/cargo-husky.svg?branch=master 164 | [travis-ci]: https://travis-ci.org/rhysd/cargo-husky 165 | [appveyor badge]: https://ci.appveyor.com/api/projects/status/whby8hq44tf9bob4/branch/master?svg=true 166 | [appveyor]: https://ci.appveyor.com/project/rhysd/cargo-husky/branch/master 167 | [crates-io badge]: https://img.shields.io/crates/v/cargo-husky.svg 168 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use fs::File; 2 | use io::{BufRead, Read, Write}; 3 | use path::{Path, PathBuf}; 4 | use std::env::var_os; 5 | use std::{env, fmt, fs, io, path}; 6 | 7 | enum Error { 8 | GitDirNotFound, 9 | Io(io::Error), 10 | OutDir(env::VarError), 11 | InvalidUserHooksDir(PathBuf), 12 | EmptyUserHook(PathBuf), 13 | } 14 | 15 | type Result = std::result::Result; 16 | 17 | impl From for Error { 18 | fn from(error: io::Error) -> Error { 19 | Error::Io(error) 20 | } 21 | } 22 | 23 | impl From for Error { 24 | fn from(error: env::VarError) -> Error { 25 | Error::OutDir(error) 26 | } 27 | } 28 | 29 | impl fmt::Debug for Error { 30 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 31 | let msg = match self { 32 | Error::GitDirNotFound => format!( 33 | ".git directory was not found in '{}' or its parent directories", 34 | env::var("OUT_DIR").unwrap_or_else(|_| "".to_string()), 35 | ), 36 | Error::Io(inner) => format!("IO error: {}", inner), 37 | Error::OutDir(env::VarError::NotPresent) => unreachable!(), 38 | Error::OutDir(env::VarError::NotUnicode(msg)) => msg.to_string_lossy().to_string(), 39 | Error::InvalidUserHooksDir(path) => { 40 | format!("User hooks directory is not found or no executable file is found in '{:?}'. Did you forget to make a hook script executable?", path) 41 | } 42 | Error::EmptyUserHook(path) => format!("User hook script is empty: {:?}", path), 43 | }; 44 | write!(f, "{}", msg) 45 | } 46 | } 47 | 48 | fn resolve_gitdir() -> Result { 49 | let dir = env::var("OUT_DIR")?; 50 | let mut dir = PathBuf::from(dir); 51 | if !dir.has_root() { 52 | dir = fs::canonicalize(dir)?; 53 | } 54 | loop { 55 | let gitdir = dir.join(".git"); 56 | if gitdir.is_dir() { 57 | return Ok(gitdir); 58 | } 59 | if gitdir.is_file() { 60 | let mut buf = String::new(); 61 | File::open(gitdir)?.read_to_string(&mut buf)?; 62 | let newlines: &[_] = &['\n', '\r']; 63 | let gitdir = PathBuf::from(buf.trim_end_matches(newlines)); 64 | if !gitdir.is_dir() { 65 | return Err(Error::GitDirNotFound); 66 | } 67 | return Ok(gitdir); 68 | } 69 | if !dir.pop() { 70 | return Err(Error::GitDirNotFound); 71 | } 72 | } 73 | } 74 | 75 | // This function returns true when 76 | // - the hook was generated by the same version of cargo-husky 77 | // - someone else had already put another hook script 78 | // For safety, cargo-husky does nothing on case2 also. 79 | fn hook_already_exists(hook: &Path) -> bool { 80 | let f = match File::open(hook) { 81 | Ok(f) => f, 82 | Err(..) => return false, 83 | }; 84 | 85 | let ver_line = match io::BufReader::new(f).lines().nth(2) { 86 | None => return true, // Less than 2 lines. The hook script seemed to be generated by someone else 87 | Some(Err(..)) => return false, // Failed to read entry. Re-generate anyway 88 | Some(Ok(line)) => line, 89 | }; 90 | 91 | if !ver_line.contains("This hook was set by cargo-husky") { 92 | // The hook script was generated by someone else. 93 | true 94 | } else { 95 | let ver_comment = format!( 96 | "This hook was set by cargo-husky v{}", 97 | env!("CARGO_PKG_VERSION") 98 | ); 99 | ver_line.contains(&ver_comment) 100 | } 101 | } 102 | 103 | fn write_script(w: &mut W) -> Result<()> { 104 | macro_rules! raw_cmd { 105 | ($c:expr) => { 106 | concat!("\necho '+", $c, "'\n", $c) 107 | }; 108 | } 109 | 110 | #[cfg(feature = "run-for-all")] 111 | macro_rules! cmd { 112 | ($c:expr) => { 113 | raw_cmd!(concat!($c, " --all")) 114 | }; 115 | ($c:expr, $subflags:expr) => { 116 | raw_cmd!(concat!($c, " --all -- ", $subflags)) 117 | }; 118 | } 119 | 120 | #[cfg(not(feature = "run-for-all"))] 121 | macro_rules! cmd { 122 | ($c:expr) => { 123 | raw_cmd!($c) 124 | }; 125 | ($c:expr, $subflags:expr) => { 126 | raw_cmd!(concat!($c, " -- ", $subflags)) 127 | }; 128 | } 129 | 130 | let script = { 131 | let mut s = String::new(); 132 | if cfg!(feature = "run-cargo-test") { 133 | s += cmd!("cargo test"); 134 | } 135 | if cfg!(feature = "run-cargo-check") { 136 | s += cmd!("cargo check"); 137 | } 138 | if cfg!(feature = "run-cargo-clippy") { 139 | s += cmd!("cargo clippy", "-D warnings"); 140 | } 141 | if cfg!(feature = "run-cargo-fmt") { 142 | s += cmd!("cargo fmt", "--check"); 143 | } 144 | s 145 | }; 146 | 147 | writeln!( 148 | w, 149 | r#"#!/bin/sh 150 | # 151 | # This hook was set by cargo-husky v{}: {} 152 | # Generated by script {}{}build.rs 153 | # Output at {} 154 | # 155 | 156 | set -e 157 | {}"#, 158 | env!("CARGO_PKG_VERSION"), 159 | env!("CARGO_PKG_HOMEPAGE"), 160 | env!("CARGO_MANIFEST_DIR"), 161 | path::MAIN_SEPARATOR, 162 | env::var("OUT_DIR").unwrap_or_else(|_| "".to_string()), 163 | script 164 | )?; 165 | Ok(()) 166 | } 167 | 168 | #[cfg(target_os = "windows")] 169 | fn create_executable_file(path: &Path) -> io::Result { 170 | File::create(path) 171 | } 172 | 173 | #[cfg(not(target_os = "windows"))] 174 | fn create_executable_file(path: &Path) -> io::Result { 175 | use std::os::unix::fs::OpenOptionsExt; 176 | 177 | fs::OpenOptions::new() 178 | .write(true) 179 | .create(true) 180 | .truncate(true) 181 | .mode(0o755) 182 | .open(path) 183 | } 184 | 185 | fn install_hook(hook: &str) -> Result<()> { 186 | let hook_path = { 187 | let mut p = resolve_gitdir()?; 188 | p.push("hooks"); 189 | p.push(hook); 190 | p 191 | }; 192 | if !hook_already_exists(&hook_path) { 193 | let mut f = create_executable_file(&hook_path)?; 194 | write_script(&mut f)?; 195 | } 196 | Ok(()) 197 | } 198 | 199 | fn install_user_hook(src: &Path, dst: &Path) -> Result<()> { 200 | if hook_already_exists(dst) { 201 | return Ok(()); 202 | } 203 | 204 | let mut lines = { 205 | let mut vec = vec![]; 206 | for line in io::BufReader::new(File::open(src)?).lines() { 207 | vec.push(line?); 208 | } 209 | vec 210 | }; 211 | 212 | if lines.is_empty() { 213 | return Err(Error::EmptyUserHook(src.to_owned())); 214 | } 215 | 216 | // Insert cargo-husky package version information as comment 217 | if !lines[0].starts_with("#!") { 218 | lines.insert(0, "#".to_string()); 219 | } 220 | lines.insert(1, "#".to_string()); 221 | lines.insert( 222 | 2, 223 | format!( 224 | "# This hook was set by cargo-husky v{}: {}", 225 | env!("CARGO_PKG_VERSION"), 226 | env!("CARGO_PKG_HOMEPAGE") 227 | ), 228 | ); 229 | 230 | let dst_file_path = dst.join(src.file_name().unwrap()); 231 | 232 | let mut f = io::BufWriter::new(create_executable_file(&dst_file_path)?); 233 | for line in lines { 234 | writeln!(f, "{}", line)?; 235 | } 236 | 237 | Ok(()) 238 | } 239 | 240 | #[cfg(target_os = "windows")] 241 | fn is_executable_file(entry: &fs::DirEntry) -> bool { 242 | match entry.file_type() { 243 | Ok(ft) => ft.is_file(), 244 | Err(..) => false, 245 | } 246 | } 247 | 248 | #[cfg(not(target_os = "windows"))] 249 | fn is_executable_file(entry: &fs::DirEntry) -> bool { 250 | use std::os::unix::fs::PermissionsExt; 251 | 252 | let ft = match entry.file_type() { 253 | Ok(ft) => ft, 254 | Err(..) => return false, 255 | }; 256 | if !ft.is_file() { 257 | return false; 258 | } 259 | let md = match entry.metadata() { 260 | Ok(md) => md, 261 | Err(..) => return false, 262 | }; 263 | let mode = md.permissions().mode(); 264 | mode & 0o555 == 0o555 // Check file is read and executable mode 265 | } 266 | 267 | fn install_user_hooks() -> Result<()> { 268 | let git_dir = resolve_gitdir()?; 269 | let user_hooks_dir = { 270 | let mut p = git_dir.clone(); 271 | p.pop(); 272 | p.push(".cargo-husky"); 273 | p.push("hooks"); 274 | p 275 | }; 276 | 277 | if !user_hooks_dir.is_dir() { 278 | return Err(Error::InvalidUserHooksDir(user_hooks_dir)); 279 | } 280 | 281 | let hook_paths = fs::read_dir(&user_hooks_dir)? 282 | .filter_map(|e| e.ok().filter(is_executable_file).map(|e| e.path())) 283 | .collect::>(); 284 | 285 | if hook_paths.is_empty() { 286 | return Err(Error::InvalidUserHooksDir(user_hooks_dir)); 287 | } 288 | 289 | let hooks_dir = git_dir.join("hooks"); 290 | for path in hook_paths { 291 | install_user_hook(&path, &hooks_dir)?; 292 | } 293 | 294 | Ok(()) 295 | } 296 | 297 | fn install() -> Result<()> { 298 | if cfg!(feature = "user-hooks") { 299 | return install_user_hooks(); 300 | } 301 | if cfg!(feature = "prepush-hook") { 302 | install_hook("pre-push")?; 303 | } 304 | if cfg!(feature = "precommit-hook") { 305 | install_hook("pre-commit")?; 306 | } 307 | if cfg!(feature = "postmerge-hook") { 308 | install_hook("post-merge")?; 309 | } 310 | Ok(()) 311 | } 312 | 313 | fn main() -> Result<()> { 314 | if var_os("CARGO_HUSKY_DONT_INSTALL_HOOKS").is_some() { 315 | eprintln!("Warning: Found '$CARGO_HUSKY_DONT_INSTALL_HOOKS' in env, not doing anything!"); 316 | return Ok(()); 317 | } 318 | 319 | match install() { 320 | Err(e @ Error::GitDirNotFound) => { 321 | // #2 322 | eprintln!("Warning: {:?}", e); 323 | Ok(()) 324 | } 325 | otherwise => otherwise, 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/test.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate lazy_static; 3 | extern crate libc; 4 | extern crate semver; 5 | 6 | use semver::Version as SemVer; 7 | use std::fs::{File, OpenOptions}; 8 | use std::io::{Read, Write}; 9 | use std::path::{Path, PathBuf}; 10 | use std::process::{Command, Output}; 11 | use std::{env, ffi, fs, str, thread, time}; 12 | 13 | lazy_static! { 14 | static ref TMPDIR_ROOT: PathBuf = { 15 | let mut tmp = env::temp_dir(); 16 | tmp.push("cargo-husky-test"); 17 | ensure_empty_dir(&tmp); 18 | 19 | unsafe { 20 | ::libc::atexit(cleanup_tmpdir); 21 | } 22 | 23 | tmp 24 | }; 25 | static ref TESTDIR: PathBuf = fs::canonicalize(file!()) 26 | .unwrap() 27 | .parent() 28 | .unwrap() 29 | .join("testdata"); 30 | } 31 | 32 | #[no_mangle] 33 | extern "C" fn cleanup_tmpdir() { 34 | if TMPDIR_ROOT.exists() { 35 | fs::remove_dir_all(TMPDIR_ROOT.as_path()).unwrap(); 36 | } 37 | } 38 | 39 | fn ensure_empty_dir(path: &Path) { 40 | if path.exists() { 41 | for entry in fs::read_dir(path).unwrap() { 42 | fs::remove_dir_all(entry.unwrap().path()).unwrap(); 43 | } 44 | } else { 45 | fs::create_dir_all(path).unwrap(); 46 | } 47 | } 48 | 49 | fn tmpdir_for(name: &str) -> PathBuf { 50 | let tmp = TMPDIR_ROOT.join(name); 51 | ensure_empty_dir(&tmp); 52 | tmp 53 | } 54 | 55 | fn open_cargo_toml(repo_dir: &Path) -> fs::File { 56 | OpenOptions::new() 57 | .write(true) 58 | .append(true) 59 | .open(repo_dir.join("Cargo.toml")) 60 | .unwrap() 61 | } 62 | 63 | fn run_cargo<'a, I, S, P>(project_root: P, args: I) -> Result 64 | where 65 | I: IntoIterator, 66 | S: AsRef, 67 | P: AsRef, 68 | { 69 | let out = Command::new("cargo") 70 | .args(args) 71 | .current_dir(&project_root) 72 | .output() 73 | .unwrap(); 74 | if out.status.success() { 75 | Ok(out) 76 | } else { 77 | Err(str::from_utf8(out.stderr.as_slice()).unwrap().to_string()) 78 | } 79 | } 80 | 81 | fn cargo_project_for(name: &str) -> PathBuf { 82 | let dir = tmpdir_for(name); 83 | run_cargo(&dir, &["init", "--lib"]).unwrap(); 84 | 85 | let mut cargo_toml = open_cargo_toml(&dir); 86 | writeln!( 87 | cargo_toml, 88 | "\n\n[patch.crates-io]\ncargo-husky = {{ path = \"{}\" }}\n\n[dev-dependencies.cargo-husky]\nversion = \"{}\"", 89 | fs::canonicalize(file!()) 90 | .unwrap() 91 | .parent() 92 | .unwrap() 93 | .parent() 94 | .unwrap() 95 | .to_string_lossy() 96 | .replace("\\", "\\\\"), 97 | env!("CARGO_PKG_VERSION"), 98 | ).unwrap(); 99 | dir 100 | } 101 | 102 | fn hook_path(root: &Path, name: &str) -> PathBuf { 103 | let mut path = root.to_owned(); 104 | path.push(".git"); 105 | path.push("hooks"); 106 | assert!(path.exists()); // hooks directory should always exist 107 | path.push(name); 108 | return path; 109 | } 110 | 111 | fn get_hook_script(root: &Path, hook: &str) -> Option { 112 | let path = hook_path(root, hook); 113 | let mut f = File::open(path).ok()?; 114 | let mut s = String::new(); 115 | f.read_to_string(&mut s).unwrap(); 116 | Some(s) 117 | } 118 | 119 | fn decrease_patch(mut ver: SemVer) -> SemVer { 120 | if ver.patch > 0 { 121 | ver.patch -= 1; 122 | return ver; 123 | } 124 | ver.patch = 9; 125 | if ver.minor > 0 { 126 | ver.minor -= 1; 127 | return ver; 128 | } 129 | ver.minor = 9; 130 | if ver.major > 0 { 131 | ver.major -= 1; 132 | return ver; 133 | } 134 | unreachable!(); 135 | } 136 | 137 | #[test] 138 | fn default_behavior() { 139 | let root = cargo_project_for("default"); 140 | run_cargo(&root, &["test"]).unwrap(); 141 | let script = get_hook_script(&root, "pre-push").unwrap(); 142 | 143 | assert_eq!(script.lines().nth(0).unwrap(), "#!/bin/sh"); 144 | assert!(script 145 | .lines() 146 | .nth(2) 147 | .unwrap() 148 | .contains(format!("set by cargo-husky v{}", env!("CARGO_PKG_VERSION")).as_str())); 149 | assert_eq!( 150 | script.lines().filter(|l| *l == "cargo test --all").count(), 151 | 1 152 | ); 153 | assert!(script.lines().all(|l| !l.contains("cargo clippy"))); 154 | 155 | assert_eq!(get_hook_script(&root, "pre-commit"), None); 156 | } 157 | 158 | #[test] 159 | #[cfg(not(target_os = "windows"))] 160 | fn hook_file_is_executable() { 161 | use std::os::unix::fs::PermissionsExt; 162 | 163 | let root = cargo_project_for("unit-permission"); 164 | run_cargo(&root, &["test"]).unwrap(); 165 | 166 | let prepush_path = hook_path(&root, "pre-push"); 167 | let mode = File::open(&prepush_path) 168 | .unwrap() 169 | .metadata() 170 | .unwrap() 171 | .permissions() 172 | .mode(); 173 | assert_eq!(mode & 0o555, 0o555); 174 | } 175 | 176 | #[test] 177 | fn change_features() { 178 | let root = cargo_project_for("features"); 179 | let mut cargo_toml = open_cargo_toml(&root); 180 | writeln!( 181 | cargo_toml, 182 | "default-features = false\nfeatures = [\"precommit-hook\", \"run-cargo-clippy\", \"run-cargo-check\", \"run-cargo-fmt\"]" 183 | ).unwrap(); 184 | run_cargo(&root, &["test"]).unwrap(); 185 | 186 | assert_eq!(get_hook_script(&root, "pre-push"), None); 187 | 188 | let script = get_hook_script(&root, "pre-commit").unwrap(); 189 | assert!(script.lines().all(|l| l != "cargo test")); 190 | assert_eq!( 191 | script 192 | .lines() 193 | .filter(|l| *l == "cargo clippy -- -D warnings") 194 | .count(), 195 | 1 196 | ); 197 | assert_eq!(script.lines().filter(|l| *l == "cargo check").count(), 1); 198 | assert_eq!( 199 | script 200 | .lines() 201 | .filter(|l| *l == "cargo fmt -- --check") 202 | .count(), 203 | 1 204 | ); 205 | } 206 | 207 | #[test] 208 | fn change_features_using_run_for_all() { 209 | let root = cargo_project_for("features_using_run_for_all"); 210 | let mut cargo_toml = open_cargo_toml(&root); 211 | writeln!( 212 | cargo_toml, 213 | "default-features = false\nfeatures = [\"precommit-hook\", \"run-for-all\", \"run-cargo-test\", \"run-cargo-check\", \"run-cargo-clippy\", \"run-cargo-fmt\"]" 214 | ).unwrap(); 215 | run_cargo(&root, &["test"]).unwrap(); 216 | 217 | assert_eq!(get_hook_script(&root, "pre-push"), None); 218 | 219 | let script = get_hook_script(&root, "pre-commit").unwrap(); 220 | assert_eq!( 221 | script.lines().filter(|l| *l == "cargo test --all").count(), 222 | 1 223 | ); 224 | assert_eq!( 225 | script 226 | .lines() 227 | .filter(|l| *l == "cargo clippy --all -- -D warnings") 228 | .count(), 229 | 1 230 | ); 231 | assert_eq!( 232 | script.lines().filter(|l| *l == "cargo check --all").count(), 233 | 1 234 | ); 235 | assert_eq!( 236 | script 237 | .lines() 238 | .filter(|l| *l == "cargo fmt --all -- --check") 239 | .count(), 240 | 1 241 | ); 242 | } 243 | 244 | #[test] 245 | fn hook_not_updated_twice() { 246 | let root = cargo_project_for("not-update-twice"); 247 | run_cargo(&root, &["test"]).unwrap(); 248 | 249 | let prepush_path = hook_path(&root, "pre-push"); 250 | 251 | let first = File::open(&prepush_path) 252 | .unwrap() 253 | .metadata() 254 | .unwrap() 255 | .modified() 256 | .unwrap(); 257 | 258 | // Remove 'target' directory to trigger compiling the package again. 259 | // When package is updated, the package is re-compiled. But here, package itself is not updated. 260 | // .git/hooks/pre-push was directly modified. So manually triggering re-compilation is necessary. 261 | fs::remove_dir_all(root.join("target")).unwrap(); 262 | 263 | // Ensure modified time differs from previous 264 | thread::sleep(time::Duration::from_secs(1)); 265 | 266 | run_cargo(&root, &["test"]).unwrap(); 267 | let second = File::open(&prepush_path) 268 | .unwrap() 269 | .metadata() 270 | .unwrap() 271 | .modified() 272 | .unwrap(); 273 | 274 | assert_eq!(first, second); // Check the second `cargo test` does not modify hook script 275 | } 276 | 277 | #[test] 278 | fn regenerate_hook_script_on_package_update() { 279 | let root = cargo_project_for("package-update"); 280 | 281 | run_cargo(&root, &["test"]).unwrap(); 282 | 283 | let prepush_path = hook_path(&root, "pre-push"); 284 | let script = get_hook_script(&root, "pre-push").unwrap(); 285 | 286 | // Replace version string in hook to older version 287 | let before = format!("set by cargo-husky v{}", env!("CARGO_PKG_VERSION")); 288 | let prev_version = decrease_patch(SemVer::parse(env!("CARGO_PKG_VERSION")).unwrap()); 289 | let after = format!("set by cargo-husky v{}", prev_version); 290 | let script = script.replacen(before.as_str(), after.as_str(), 1); 291 | 292 | let modified_before = { 293 | let mut f = OpenOptions::new() 294 | .write(true) 295 | .read(true) 296 | .truncate(true) 297 | .open(&prepush_path) 298 | .unwrap(); 299 | write!(f, "{}", script).unwrap(); 300 | f.metadata().unwrap().modified().unwrap() 301 | }; 302 | 303 | // Remove 'target' directory to trigger compiling the package again. 304 | // When package is updated, the package is re-compiled. But here, package itself is not updated. 305 | // .git/hooks/pre-push was directly modified. So manually triggering re-compilation is necessary. 306 | fs::remove_dir_all(root.join("target")).unwrap(); 307 | 308 | // Ensure modified time differs from previous 309 | thread::sleep(time::Duration::from_secs(1)); 310 | 311 | run_cargo(&root, &["test"]).unwrap(); 312 | 313 | let modified_after = File::open(&prepush_path) 314 | .unwrap() 315 | .metadata() 316 | .unwrap() 317 | .modified() 318 | .unwrap(); 319 | // Modified time differs since the hook script was re-generated 320 | assert_ne!(modified_before, modified_after); 321 | 322 | // Check the version is updated in hook script 323 | let script = get_hook_script(&root, "pre-push").unwrap(); 324 | assert!(script 325 | .lines() 326 | .nth(2) 327 | .unwrap() 328 | .contains(format!("set by cargo-husky v{}", env!("CARGO_PKG_VERSION")).as_str())); 329 | } 330 | 331 | macro_rules! another_hook_test { 332 | ($testcase:ident, $content:expr) => { 333 | #[test] 334 | fn $testcase() { 335 | let root = cargo_project_for(stringify!($testcase)); 336 | let prepush_path = hook_path(&root, "pre-push"); 337 | let content = $content.to_string(); 338 | let modified_before = { 339 | let mut f = File::create(&prepush_path).unwrap(); 340 | writeln!(f, "{}", content).unwrap(); 341 | f.metadata().unwrap().modified().unwrap() 342 | }; 343 | 344 | // Ensure modified time differs from previous if file were updated 345 | thread::sleep(time::Duration::from_secs(1)); 346 | 347 | run_cargo(&root, &["test"]).unwrap(); 348 | 349 | let modified_after = File::open(&prepush_path) 350 | .unwrap() 351 | .metadata() 352 | .unwrap() 353 | .modified() 354 | .unwrap(); 355 | 356 | assert_eq!(modified_before, modified_after); 357 | 358 | let script = get_hook_script(&root, "pre-push").unwrap(); 359 | assert_eq!(content + "\n", script); 360 | } 361 | }; 362 | } 363 | 364 | another_hook_test!( 365 | another_hook_less_than_3_lines, 366 | "#!/bin/sh\necho 'hook put by someone else'" 367 | ); 368 | another_hook_test!( 369 | another_hook_more_than_3_lines, 370 | "#!/bin/sh\n\n\necho 'hook put by someone else'" 371 | ); 372 | 373 | fn copy_dir_recursive(from: &Path, to: &Path) { 374 | if !to.exists() { 375 | fs::create_dir_all(to).unwrap(); 376 | } 377 | for entry in fs::read_dir(from).unwrap() { 378 | let entry = entry.unwrap(); 379 | let child_from = entry.path(); 380 | let child_to = to.join(child_from.strip_prefix(from).unwrap()); 381 | if entry.file_type().unwrap().is_dir() { 382 | copy_dir_recursive(&child_from, &child_to); 383 | } else { 384 | fs::copy(child_from, child_to).unwrap(); 385 | } 386 | } 387 | } 388 | 389 | fn setup_user_hooks_feature(root: &Path) { 390 | let mut cargo_toml = open_cargo_toml(&root); 391 | writeln!( 392 | cargo_toml, 393 | "default-features = false\nfeatures = [\"user-hooks\"]" // pre-push will be ignored 394 | ) 395 | .unwrap(); 396 | } 397 | 398 | #[test] 399 | fn user_hooks() { 400 | let root = cargo_project_for("user-hooks"); 401 | setup_user_hooks_feature(&root); 402 | 403 | let user_hooks = TESTDIR.join("user-hooks"); 404 | copy_dir_recursive(&user_hooks.join(".cargo-husky"), &root.join(".cargo-husky")); 405 | 406 | run_cargo(&root, &["test"]).unwrap(); 407 | 408 | assert!(!hook_path(&root, "pre-push").exists()); // Default features are ignored 409 | assert!(hook_path(&root, "pre-commit").is_file()); 410 | assert!(hook_path(&root, "post-merge").is_file()); 411 | 412 | let check_line = format!( 413 | "# This hook was set by cargo-husky v{}: {}", 414 | env!("CARGO_PKG_VERSION"), 415 | env!("CARGO_PKG_HOMEPAGE") 416 | ); 417 | 418 | let s = get_hook_script(&root, "pre-commit").unwrap(); 419 | assert_eq!(s.lines().nth(0), Some("#! /bin/sh")); 420 | assert_eq!(s.lines().nth(2), Some(check_line.as_str())); 421 | assert_eq!( 422 | s.lines().nth(4), 423 | Some("# This is a user script for pre-commit hook with shebang") 424 | ); 425 | 426 | let s = get_hook_script(&root, "post-merge").unwrap(); 427 | assert_eq!(s.lines().nth(0), Some("#")); 428 | assert_eq!(s.lines().nth(2), Some(check_line.as_str())); 429 | assert_eq!( 430 | s.lines().nth(3), 431 | Some("# Script without shebang (I'm not sure this is useful)") 432 | ); 433 | } 434 | 435 | fn assert_user_hooks_error(root: &Path) { 436 | match run_cargo(&root, &["test"]) { 437 | Ok(out) => assert!( 438 | false, 439 | "`cargo test` has unexpectedly successfully done: {:?}", 440 | out 441 | ), 442 | Err(err) => assert!( 443 | format!("{}", err) 444 | .contains("User hooks directory is not found or no executable file is found in"), 445 | "Unexpected output on `cargo test`: {}", 446 | err 447 | ), 448 | } 449 | } 450 | 451 | #[test] 452 | fn user_hooks_dir_not_found() { 453 | let root = cargo_project_for("user-hooks-dir-not-found"); 454 | setup_user_hooks_feature(&root); 455 | assert_user_hooks_error(&root); 456 | } 457 | 458 | #[test] 459 | fn user_hooks_dir_is_empty() { 460 | for (idx, dir_path) in [ 461 | PathBuf::from(".cargo-husky"), 462 | Path::new(".cargo-husky").join("hooks"), 463 | ] 464 | .iter() 465 | .enumerate() 466 | { 467 | let root = cargo_project_for(&format!("user-hooks-dir-empty-{}", idx)); 468 | setup_user_hooks_feature(&root); 469 | 470 | fs::create_dir_all(&dir_path).unwrap(); 471 | 472 | assert_user_hooks_error(&root); 473 | } 474 | } 475 | 476 | #[test] 477 | #[cfg(not(target_os = "windows"))] 478 | fn user_hooks_dir_only_contains_non_executable_file() { 479 | let root = cargo_project_for("user-hooks-dir-without-executables"); 480 | setup_user_hooks_feature(&root); 481 | 482 | let mut p = root.join(".cargo-husky"); 483 | p.push("hooks"); 484 | fs::create_dir_all(&p).unwrap(); 485 | let f1 = p.join("non-executable-file1"); 486 | writeln!(File::create(&f1).unwrap(), "this\nis\nnormal\ntest\nfile").unwrap(); 487 | assert!(f1.exists()); 488 | let f2 = p.join("non-executable-file2"); 489 | writeln!( 490 | File::create(&f2).unwrap(), 491 | "this\nis\nalso\nnormal\ntest\nfile" 492 | ) 493 | .unwrap(); 494 | assert!(f2.exists()); 495 | 496 | assert_user_hooks_error(&root); 497 | } 498 | 499 | #[test] 500 | #[cfg(not(target_os = "windows"))] 501 | fn copied_user_hooks_are_executable() { 502 | use std::os::unix::fs::PermissionsExt; 503 | 504 | let root = cargo_project_for("copied-user-hooks-are-executable"); 505 | setup_user_hooks_feature(&root); 506 | 507 | let mut p = root.join(".cargo-husky"); 508 | 509 | let user_hooks = TESTDIR.join("user-hooks"); 510 | copy_dir_recursive(&user_hooks.join(".cargo-husky"), &p); 511 | 512 | p.push("hooks"); 513 | p.push("non-executable-file.txt"); 514 | writeln!(File::create(p).unwrap(), "foo\nbar\npiyo").unwrap(); 515 | 516 | run_cargo(&root, &["test"]).unwrap(); 517 | 518 | for name in &["pre-commit", "post-merge"] { 519 | let hook = File::open(hook_path(&root, name)).unwrap(); 520 | let mode = hook.metadata().unwrap().permissions().mode(); 521 | assert_eq!(mode & 0o555, 0o555); 522 | } 523 | 524 | assert!(!hook_path(&root, "non-executable-file.txt").exists()); 525 | } 526 | 527 | #[test] 528 | fn empty_script_file_not_allowed() { 529 | let root = cargo_project_for("empty-user-hook"); 530 | setup_user_hooks_feature(&root); 531 | 532 | let user_hooks = TESTDIR.join("empty-user-hook"); 533 | copy_dir_recursive(&user_hooks.join(".cargo-husky"), &root.join(".cargo-husky")); 534 | 535 | let err = run_cargo(&root, &["test"]).unwrap_err(); 536 | assert!(format!("{}", err).contains("User hook script is empty")); 537 | } 538 | -------------------------------------------------------------------------------- /test/testdata/empty-user-hook/.cargo-husky/hooks/pre-push: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhysd/cargo-husky/45febeeefca4febf3dc867646a43bf1cb2eceb3a/test/testdata/empty-user-hook/.cargo-husky/hooks/pre-push -------------------------------------------------------------------------------- /test/testdata/user-hooks/.cargo-husky/hooks/post-merge: -------------------------------------------------------------------------------- 1 | # Script without shebang (I'm not sure this is useful) 2 | 3 | ../../do-some-test.sh 4 | -------------------------------------------------------------------------------- /test/testdata/user-hooks/.cargo-husky/hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | # This is a user script for pre-commit hook with shebang 4 | ../../do-some-test.sh 5 | --------------------------------------------------------------------------------