Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672//! 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<String> { 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<PathBuf> { 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<Installed> { 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<Option<String>> { 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<PathBuf>, }, /// 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::<Vec<_>>() .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<Declared> { 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 ); }}