//! Installing the `commit-msg` hook that stamps change-ids. //! //! A stacked pull request is matched to its commits by a `Change-Id:` //! trailer, and the only moment that trailer can be written without //! rewriting anybody's history is while the commit is being made. So atgc //! installs a hook, rather than asking people to remember a flag. The script //! is `scripts/change-id-hook.sh` in this repository, compiled in by //! [`HOOK`], and it does nothing at all to a commit that already has an id. //! //! # Whose slot is it //! //! git runs exactly one file per hook name, so `.git/hooks/commit-msg` has an //! owner, and in any repo using prek, pre-commit, husky or lefthook that owner //! is the framework. Clobbering it would turn off whatever it runs — in this //! project, the Conventional Commits check — and the framework would clobber //! us back on its next install. //! //! So atgc takes the slot only when it is free, and otherwise takes nothing. //! There is a tempting third option — prek runs a `commit-msg.legacy` file //! beside its own, and writing one works whether it is put there before or //! after `prek install` (checked, both orders) — but that is prek's //! behaviour rather than git's, it is not documented as an interface, and //! `prek install --overwrite` removes legacy hooks. A hook that stops //! running without saying so is worse than one that was never installed. //! //! What a framework repo gets instead is a sentence naming its own config as //! the place to add the script, which is the framework's documented extension //! point and lands in version control where the rest of the team gets it too. //! This repository does exactly that: see the `change-id` hook in //! `prek.toml`. use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; /// The hook itself, compiled in: atgc installs this into repositories that /// have never heard of it, so it cannot live as a file beside the binary. const HOOK: &str = include_str!("../../../scripts/change-id-hook.sh"); /// The line that says a hook file is atgc's, whatever version wrote it. The /// script carries `atgc-change-id-hook v1`; this matches every version of /// that, because recognizing our own older hooks is what makes an upgrade /// replace one instead of declining the slot to it. const MARKER: &str = "atgc-change-id-hook v"; /// What installing found, and did. #[derive(Debug, PartialEq, Eq)] pub enum Installed { /// Written into a slot that was free. Wrote(PathBuf), /// Ours already, and the same version: nothing written. Current(PathBuf), /// Ours, from an older version of atgc, and replaced. Updated(PathBuf), /// A hook framework owns the slot. Nothing is written: its config is /// the place this belongs, and the name is carried so the caller can say /// which config that is. Framework { path: PathBuf, name: &'static str }, /// A hand-written hook owns the slot. Nothing is written — a repo whose /// hooks are maintained by hand is a repo whose owner has opinions about /// hooks. Occupied(PathBuf), } impl Installed { /// The one-line report, or `None` when there is nothing worth saying — /// which is the common case, a hook that was already in place. pub fn describe(&self) -> Option { match self { Installed::Current(_) => None, Installed::Wrote(path) => Some(format!( "installed the change-id commit-msg hook: {}", path.display() )), Installed::Updated(path) => Some(format!( "updated the change-id commit-msg hook: {}", path.display() )), Installed::Framework { path, name } => Some(format!( "{name} owns {}, so the change-id hook was not installed\n\ add it to your {name} config as a commit-msg hook running \ scripts/change-id-hook.sh, or keep using `--add-change-ids`", path.display() )), Installed::Occupied(path) => Some(format!( "left {} alone: it is not atgc's to replace\n\ have it run `atgc`'s scripts/change-id-hook.sh, or keep using \ `--add-change-ids`", path.display() )), } } } /// Whether a hook file is one of the dispatchers a hook framework installs. /// /// Matched on a marker in the file, since the rest is each tool's own shell. /// prek's banner is the one read from a real install here; the others are /// from their documented output and are guesses until somebody hits one. /// Guessing wrong costs only wording: an unrecognized hook is treated as /// hand-written, and atgc declines the slot either way. fn framework(text: &str) -> Option<&'static str> { [ ("generated by prek", "prek"), ("generated by pre-commit", "pre-commit"), ("hook-impl", "pre-commit"), ("husky", "husky"), ("lefthook", "lefthook"), ] .iter() .find(|(mark, _)| text.contains(mark)) .map(|(_, name)| *name) } fn is_ours(text: &str) -> bool { text.contains(MARKER) } /// Where this checkout's hooks live: `core.hooksPath` when set, the /// repository's shared hooks directory otherwise. /// /// `git rev-parse --git-path hooks` answers all of it, including the case /// this project lives in — inside a linked worktree, where it resolves to the /// *common* directory, so one install covers every worktree of the repo. fn hooks_dir(dir: &Path) -> Result { let path = super::run::git_in(dir, &["rev-parse", "--git-path", "hooks"])?; let path = PathBuf::from(path.trim()); Ok(match path.is_absolute() { true => path, false => dir.join(path), }) } /// Install the hook, if a slot for it is free. Idempotent, silent, and /// never destructive: see the module docs for which slot and why. pub fn install_commit_msg(dir: &Path) -> Result { let hooks = hooks_dir(dir)?; let slot = hooks.join("commit-msg"); match read_hook(&slot)? { // Nobody has it. None => { write_hook(&slot)?; Ok(Installed::Wrote(slot)) } // Ours already: current, or an older version to replace. Some(text) if is_ours(&text) => match text == HOOK { true => Ok(Installed::Current(slot)), false => { write_hook(&slot)?; Ok(Installed::Updated(slot)) } }, Some(text) => match framework(&text) { Some(name) => Ok(Installed::Framework { path: slot, name }), None => Ok(Installed::Occupied(slot)), }, } } fn read_hook(path: &Path) -> Result> { match std::fs::read_to_string(path) { Ok(text) => Ok(Some(text)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), // A hook that cannot be read is a hook that cannot be judged, and // overwriting it on a permissions error is the one outcome worth // ruling out. Err(e) => Err(e).context(format!("reading the hook at {}", path.display())), } } fn write_hook(path: &Path) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .context(format!("creating the hooks directory {}", parent.display()))?; } std::fs::write(path, HOOK).context(format!("writing the hook at {}", path.display()))?; // A hook git cannot execute is a hook git skips, silently, which is the // failure this whole thing exists to avoid. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(path)?.permissions(); perms.set_mode(0o755); std::fs::set_permissions(path, perms) .context(format!("making {} executable", path.display()))?; } Ok(()) } /// Where a repo that uses a hook framework keeps its config, and what to /// call that framework when talking about it. const CONFIGS: &[(&str, &str)] = &[ ("prek.toml", "prek"), (".pre-commit-config.yaml", "pre-commit"), (".pre-commit-config.yml", "pre-commit"), ]; /// Where the script goes when a framework runs it: a tracked file, because /// the framework's config is tracked and an entry pointing at something only /// one machine has is an entry that fails for everybody else. pub const SCRIPT_PATH: &str = "scripts/change-id-hook.sh"; /// How the change-id hook is wired up in this checkout, as `doctor` reports /// it. The good answers are all "a commit here gets an id"; the rest are the /// ways it silently would not. #[derive(Debug, PartialEq, Eq)] pub enum Wiring { /// atgc's hook holds the `commit-msg` slot. Direct(PathBuf), /// A hook framework's config declares the script, so the framework runs /// it. Carries the framework's name and its config file. Declared { name: &'static str, config: PathBuf }, /// A framework owns the slot and does not declare the script: a commit /// here gets no id, and the framework is why. FrameworkSilent { name: &'static str, config: Option, }, /// A hand-written hook holds the slot. Foreign(PathBuf), /// Nothing is wired up at all. Missing, /// Not a git checkout, or its hooks directory cannot be read. Unknown, } /// Which framework config this repo has, if any. fn config_of(root: &Path) -> Option<(&'static str, PathBuf)> { CONFIGS.iter().find_map(|(file, name)| { let path = root.join(file); path.exists().then_some((*name, path)) }) } /// Whether a framework config already runs the script. Matched on the script /// path rather than on a hook id, because the path is the part that has to be /// right for the hook to do anything, and somebody renaming the id is not /// breaking anything this needs to notice. fn declares_script(config: &Path) -> bool { std::fs::read_to_string(config) .map(|text| text.contains("change-id-hook.sh")) .unwrap_or(false) } /// What `doctor` reports about the hook, for a checkout rooted at `root`. pub fn wiring(dir: &Path, root: &Path) -> Wiring { let Ok(hooks) = hooks_dir(dir) else { return Wiring::Unknown; }; let slot = hooks.join("commit-msg"); let existing = match read_hook(&slot) { Ok(existing) => existing, Err(_) => return Wiring::Unknown, }; let config = config_of(root); // A declaration is checked first: it is the answer that stays true when // the framework rewrites its hook file, and in a repo that has one the // slot belongs to the framework anyway. if let Some((name, path)) = &config && declares_script(path) { return Wiring::Declared { name, config: path.clone(), }; } match existing { None => Wiring::Missing, Some(text) if is_ours(&text) => Wiring::Direct(slot), Some(text) => match framework(&text) { Some(name) => Wiring::FrameworkSilent { name, config: config.map(|(_, path)| path), }, None => Wiring::Foreign(slot), }, } } /// What declaring the hook in a framework's config did. #[derive(Debug, PartialEq, Eq)] pub enum Declared { /// The config now runs the script, which was also written to /// [`SCRIPT_PATH`] if it was not there already. Both are tracked files, /// and the caller says so: they are the caller's to commit. Wrote { config: PathBuf, script: PathBuf }, /// Already declared; nothing written. Already(PathBuf), /// No framework config in this repo, so there is nothing to declare it /// in — `install_commit_msg` is the path for that repo, and has run. NoConfig, /// The config exists but could not be edited safely. Carries why, since a /// refusal that does not say what it wanted is a refusal nobody acts on. Refused { config: PathBuf, why: String }, } /// The block appended to a prek/pre-commit TOML config. fn toml_block() -> String { format!( "\n# Stamp a Change-Id trailer on commits that lack one: the identity a stacked\n\ # pull request is matched to its commits by. Added by `atgc repo configure\n\ # --hook-config`; the script is atgc's, and does nothing to a commit that has\n\ # an id already.\n\ [[repos]]\n\ repo = \"local\"\n\ hooks = [\n\ \x20 {{ id = \"change-id\", name = \"change-id\", entry = \"{SCRIPT_PATH}\", \ language = \"system\", stages = [\"commit-msg\"], always_run = true }},\n\ ]\n" ) } /// The list item appended to a pre-commit YAML config, at `indent`. fn yaml_block(indent: &str) -> String { format!( "{indent}# Stamp a Change-Id trailer on commits that lack one (added by atgc).\n\ {indent}- repo: local\n\ {indent} hooks:\n\ {indent} - id: change-id\n\ {indent} name: change-id\n\ {indent} entry: {SCRIPT_PATH}\n\ {indent} language: system\n\ {indent} stages: [commit-msg]\n\ {indent} always_run: true\n" ) } /// Whether appending to this TOML can only mean "a new top-level table". /// /// There is no TOML parser in this crate, and pulling one in to append four /// lines would be the tail wagging the dog — but appending blind to a file /// that ends inside a multi-line string or an unclosed array would corrupt /// it. So the check is the crude one that catches exactly those: multi-line /// string delimiters that pair up, and brackets that balance. Anything else /// refuses and hands over the snippet instead. fn toml_appendable(text: &str) -> std::result::Result<(), String> { let triple_double = text.matches("\"\"\"").count(); let triple_single = text.matches("'''").count(); if !triple_double.is_multiple_of(2) || !triple_single.is_multiple_of(2) { return Err("it ends inside a multi-line string".to_string()); } let code: String = text .lines() .map(|line| line.split('#').next().unwrap_or("")) .collect::>() .join("\n"); if code.matches('[').count() != code.matches(']').count() { return Err("its brackets do not balance, so where it ends is not clear".to_string()); } Ok(()) } /// Declare the hook in this repo's framework config, writing the script /// beside it. /// /// Both files are tracked, which is the point — a hook everybody gets is a /// hook that lives in the repo — and also why this is a flag rather than /// something `repo configure` does on its own: it leaves changes in the /// working tree for somebody to read and commit. pub fn declare_in_config(root: &Path) -> Result { let Some((_name, config)) = config_of(root) else { return Ok(Declared::NoConfig); }; if declares_script(&config) { return Ok(Declared::Already(config)); } let text = std::fs::read_to_string(&config).context(format!("reading {}", config.display()))?; let addition = match config.extension().and_then(|e| e.to_str()) { Some("toml") => match toml_appendable(&text) { Ok(()) => toml_block(), Err(why) => return Ok(Declared::Refused { config, why }), }, _ => { // The indentation of the items already in `repos:`, since a YAML // item appended at the wrong depth is a different document. let Some(indent) = text.lines().find_map(|line| { let trimmed = line.trim_start(); trimmed .starts_with("- repo:") .then(|| line[..line.len() - trimmed.len()].to_string()) }) else { return Ok(Declared::Refused { config, why: "it has no `- repo:` item to match the indentation of".to_string(), }); }; // Only safe while the file still ends inside that list: a // top-level key after it would swallow the new item. let ends_in_list = text .lines() .rev() .find(|line| !line.trim().is_empty()) .is_some_and(|line| line.starts_with([' ', '\t', '-'])); if !ends_in_list { return Ok(Declared::Refused { config, why: "it does not end inside the repos list".to_string(), }); } yaml_block(&indent) } }; let script = root.join(SCRIPT_PATH); match read_hook(&script)? { Some(existing) if !is_ours(&existing) => { return Ok(Declared::Refused { config, why: format!("{} exists and is not atgc's", script.display()), }); } _ => write_hook(&script)?, } let mut text = text; if !text.ends_with('\n') { text.push('\n'); } text.push_str(&addition); std::fs::write(&config, text).context(format!("writing {}", config.display()))?; Ok(Declared::Wrote { config, script }) } #[cfg(test)] mod tests { use super::*; use crate::testutil::TempRepo; /// The hook carries the marker the installer recognizes it by. Two /// constants that have to agree, in two files, with no compiler between /// them. #[test] fn the_script_carries_the_marker_the_installer_looks_for() { assert!(HOOK.contains(MARKER), "the hook lost its version marker"); assert!( HOOK.contains(&format!("{MARKER}1")), "the hook's marker carries no version" ); assert!(is_ours(HOOK)); assert!( framework(HOOK).is_none(), "our own hook reads as a framework's" ); } /// An empty slot is taken, and a second run writes nothing. #[test] fn an_empty_slot_is_taken_once() { let repo = TempRepo::new("hook-empty"); let here = Path::new("."); let first = install_commit_msg(here).unwrap(); let path = match &first { Installed::Wrote(path) => path.clone(), other => panic!("expected a write, got {other:?}"), }; assert!(path.ends_with("commit-msg"), "{path:?}"); assert_eq!(std::fs::read_to_string(&path).unwrap(), HOOK); assert_eq!(install_commit_msg(here).unwrap(), Installed::Current(path)); // And the hook actually fires: the id is on the commit, which is the // only claim any of this is making. repo.commit("a.txt", "one\n", "feat: a thing"); let id = super::super::patch::change_id("HEAD").unwrap(); assert!(id.is_some(), "the installed hook stamped nothing"); } /// A framework's dispatcher keeps its slot and nothing is written: its /// own config is where the script belongs, and the report says so. #[test] fn a_frameworks_hook_is_never_clobbered() { let _repo = TempRepo::new("hook-framework"); let here = Path::new("."); let hooks = hooks_dir(here).unwrap(); std::fs::create_dir_all(&hooks).unwrap(); let dispatcher = "#!/bin/sh\n# File generated by prek: https://github.com/j178/prek\n"; std::fs::write(hooks.join("commit-msg"), dispatcher).unwrap(); let installed = install_commit_msg(here).unwrap(); assert_eq!( installed, Installed::Framework { path: hooks.join("commit-msg"), name: "prek", } ); assert_eq!( std::fs::read_to_string(hooks.join("commit-msg")).unwrap(), dispatcher, "the framework's dispatcher was overwritten" ); // Nothing beside it either: a `.legacy` file is prek's convention, // not git's, and `prek install --overwrite` deletes those. assert!(!hooks.join("commit-msg.legacy").exists()); let said = installed.describe().expect("a framework repo is told why"); assert!(said.contains("prek"), "{said}"); assert!(said.contains("scripts/change-id-hook.sh"), "{said}"); } /// A hand-written hook is left exactly as it is, and said so about. #[test] fn a_hand_written_hook_is_left_alone() { let _repo = TempRepo::new("hook-foreign"); let here = Path::new("."); let hooks = hooks_dir(here).unwrap(); std::fs::create_dir_all(&hooks).unwrap(); let mine = "#!/bin/sh\necho my own hook\n"; std::fs::write(hooks.join("commit-msg"), mine).unwrap(); let installed = install_commit_msg(here).unwrap(); assert_eq!(installed, Installed::Occupied(hooks.join("commit-msg"))); assert_eq!( std::fs::read_to_string(hooks.join("commit-msg")).unwrap(), mine ); assert!( installed.describe().is_some(), "silence would be wrong here" ); } /// A prek repo gets the entry appended and the script written, and a /// second run does nothing: the whole point of the flag being safe to /// rerun from a doctor's fix line. #[test] fn declaring_appends_the_entry_and_writes_the_script() { let repo = TempRepo::new("hook-declare-toml"); let root = std::env::current_dir().unwrap(); std::fs::write( root.join("prek.toml"), "[[repos]]\nrepo = \"local\"\nhooks = [{ id = \"fmt\", entry = \"cargo fmt\" }]\n", ) .unwrap(); let done = declare_in_config(&root).unwrap(); let (config, script) = match done { Declared::Wrote { config, script } => (config, script), other => panic!("expected a write, got {other:?}"), }; let text = std::fs::read_to_string(&config).unwrap(); assert!(text.contains("cargo fmt"), "the existing config was lost"); assert!(text.contains(SCRIPT_PATH), "the entry names no script"); assert!(text.contains("stages = [\"commit-msg\"]"), "{text}"); assert_eq!(std::fs::read_to_string(&script).unwrap(), HOOK); assert_eq!( declare_in_config(&root).unwrap(), Declared::Already(config), "a second run must write nothing" ); // And the repo it landed in still works, which is the only claim the // file layout is making. repo.commit("a.txt", "one\n", "feat: a thing"); } /// A YAML config gets an item at the indentation its list already uses. #[test] fn declaring_matches_the_yaml_indentation() { let _repo = TempRepo::new("hook-declare-yaml"); let root = std::env::current_dir().unwrap(); std::fs::write( root.join(".pre-commit-config.yaml"), "repos:\n - repo: local\n hooks:\n - id: fmt\n entry: cargo fmt\n", ) .unwrap(); let done = declare_in_config(&root).unwrap(); let config = match done { Declared::Wrote { config, .. } => config, other => panic!("expected a write, got {other:?}"), }; let text = std::fs::read_to_string(&config).unwrap(); assert!( text.contains("\n - repo: local\n hooks:\n - id: change-id"), "{text}" ); } /// A config this cannot append to safely is left exactly as it is, and /// says which part it could not read. #[test] fn an_unreadable_config_is_refused_rather_than_appended_to() { let _repo = TempRepo::new("hook-declare-refuse"); let root = std::env::current_dir().unwrap(); // Ends inside a multi-line string: appending would land inside it. let broken = "[tool]\nnote = \"\"\"\nunterminated\n"; std::fs::write(root.join("prek.toml"), broken).unwrap(); match declare_in_config(&root).unwrap() { Declared::Refused { why, .. } => assert!(why.contains("multi-line string"), "{why}"), other => panic!("expected a refusal, got {other:?}"), } assert_eq!( std::fs::read_to_string(root.join("prek.toml")).unwrap(), broken, "a config that could not be read was written to anyway" ); assert!( !root.join(SCRIPT_PATH).exists(), "the script was written for a declaration that never happened" ); } /// What `doctor` says, in the three states that matter: a framework /// running it, a framework silent about it, and nothing at all. #[test] fn wiring_reports_who_runs_the_hook() { let _repo = TempRepo::new("hook-wiring"); let here = Path::new("."); let root = std::env::current_dir().unwrap(); assert_eq!(wiring(here, &root), Wiring::Missing); let hooks = hooks_dir(here).unwrap(); std::fs::create_dir_all(&hooks).unwrap(); std::fs::write( hooks.join("commit-msg"), "#!/bin/sh\n# File generated by prek\n", ) .unwrap(); assert_eq!( wiring(here, &root), Wiring::FrameworkSilent { name: "prek", config: None }, "a framework with no config we know of is still silent about the hook" ); std::fs::write(root.join("prek.toml"), "hooks = []\n").unwrap(); assert!(matches!( wiring(here, &root), Wiring::FrameworkSilent { config: Some(_), .. } )); declare_in_config(&root).unwrap(); assert_eq!( wiring(here, &root), Wiring::Declared { name: "prek", config: root.join("prek.toml") }, ); } /// An older copy of our own hook is replaced rather than reported. #[test] fn an_older_atgc_hook_is_updated_in_place() { let _repo = TempRepo::new("hook-upgrade"); let here = Path::new("."); let hooks = hooks_dir(here).unwrap(); std::fs::create_dir_all(&hooks).unwrap(); std::fs::write( hooks.join("commit-msg"), "#!/bin/sh\n# atgc-change-id-hook v0\nexit 0\n", ) .unwrap(); assert_eq!( install_commit_msg(here).unwrap(), Installed::Updated(hooks.join("commit-msg")) ); assert_eq!( std::fs::read_to_string(hooks.join("commit-msg")).unwrap(), HOOK ); } }