357 lines
14 KiB
Rust
357 lines
14 KiB
Rust
//! `bread hooks install git` — installs small, non-blocking git hooks that
|
|
//! emit normalized events (via the `bread-emit` fire-and-forget binary) on
|
|
//! commit and branch-change activity.
|
|
//!
|
|
//! Design constraints this module is built around:
|
|
//!
|
|
//! - Never touch a hook file bread doesn't own. Frameworks like Husky or the
|
|
//! `pre-commit` tool, or a developer's own scripts, commonly already
|
|
//! occupy `post-commit` / `post-checkout` / `post-merge`. We only ever
|
|
//! overwrite a hook file if it already carries our marker comment (meaning
|
|
//! we wrote it on a previous install); otherwise we skip it and tell the
|
|
//! user exactly what to add by hand.
|
|
//! - Never make git itself slower or block a commit/checkout/merge because
|
|
//! breadd is slow or down. The installed scripts background `bread-emit`
|
|
//! and unconditionally exit 0.
|
|
//! - Respect `core.hooksPath`. If the user has repointed hooks elsewhere, we
|
|
//! do not silently write into `.git/hooks` where nothing will ever run
|
|
//! them — see [`install_git`] for the exact behavior.
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
/// Distinctive marker comment written as the second line of every hook
|
|
/// script bread installs. Its presence is how we tell "a hook we installed
|
|
/// previously, safe to overwrite" apart from "someone else's hook, hands
|
|
/// off." Keep this stable across versions — changing it would make bread
|
|
/// think its own previously-installed hooks belong to someone else.
|
|
pub const MARKER: &str = "# bread-managed-hook";
|
|
|
|
/// The three git hooks bread installs, in a stable order for display.
|
|
const HOOK_NAMES: [&str; 3] = ["post-commit", "post-checkout", "post-merge"];
|
|
|
|
/// Outcome of attempting to install a single hook file.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum HookOutcome {
|
|
Installed,
|
|
Skipped,
|
|
}
|
|
|
|
/// Install bread's git hooks (`post-commit`, `post-checkout`, `post-merge`)
|
|
/// into the current working directory's git repository.
|
|
///
|
|
/// This only ever touches the repo rooted at the current directory (via
|
|
/// `git rev-parse`, which correctly follows worktrees/submodules to the
|
|
/// real git dir) — never a global `core.hooksPath`, never other repos.
|
|
pub fn install_git() -> Result<()> {
|
|
let git_dir = git_dir()?;
|
|
let toplevel = show_toplevel()?;
|
|
|
|
if let Some(configured) = hooks_path_override()? {
|
|
print_hooks_path_warning(&configured);
|
|
bail!(
|
|
"bread: refusing to install into '{}/hooks' while core.hooksPath is set to '{}'",
|
|
git_dir.display(),
|
|
configured
|
|
);
|
|
}
|
|
|
|
let hooks_dir = git_dir.join("hooks");
|
|
fs::create_dir_all(&hooks_dir)
|
|
.with_context(|| format!("failed to create {}", hooks_dir.display()))?;
|
|
|
|
let mut installed = Vec::new();
|
|
let mut skipped = Vec::new();
|
|
|
|
for &name in HOOK_NAMES.iter() {
|
|
let path = hooks_dir.join(name);
|
|
let script = hook_script(name);
|
|
match install_one_hook(&path, &script)? {
|
|
HookOutcome::Installed => installed.push(path),
|
|
HookOutcome::Skipped => skipped.push(path),
|
|
}
|
|
}
|
|
|
|
print_summary(&toplevel, &installed, &skipped);
|
|
Ok(())
|
|
}
|
|
|
|
/// Install (or skip) a single hook file at `path` with contents `script`.
|
|
///
|
|
/// Never overwrites an existing file unless it already carries our marker.
|
|
fn install_one_hook(path: &Path, script: &str) -> Result<HookOutcome> {
|
|
if path.exists() {
|
|
let existing = fs::read_to_string(path)
|
|
.with_context(|| format!("failed to read existing hook {}", path.display()))?;
|
|
if !is_bread_managed(&existing) {
|
|
eprintln!(
|
|
"bread: '{}' already exists and was not installed by bread — leaving it \
|
|
untouched.\n To also emit bread events from it, add this line to the end \
|
|
of the existing script:\n\n {}\n",
|
|
path.display(),
|
|
emit_line_for(path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
|
|
);
|
|
return Ok(HookOutcome::Skipped);
|
|
}
|
|
// It's ours from a previous install — safe to overwrite.
|
|
}
|
|
|
|
fs::write(path, script).with_context(|| format!("failed to write hook {}", path.display()))?;
|
|
let mut perms = fs::metadata(path)
|
|
.with_context(|| format!("failed to stat {}", path.display()))?
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(path, perms)
|
|
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
|
|
|
|
Ok(HookOutcome::Installed)
|
|
}
|
|
|
|
/// Whether `contents` was written by a previous bread install (contains the
|
|
/// marker comment anywhere in the file).
|
|
fn is_bread_managed(contents: &str) -> bool {
|
|
contents.lines().any(|line| line.trim() == MARKER)
|
|
}
|
|
|
|
/// The bare `bread-emit` invocation for a branch checkout, with no guard —
|
|
/// callers that already run inside a `[ "$3" = "1" ]` check (our own
|
|
/// generated hook script) use this directly.
|
|
fn branch_changed_emit_line() -> String {
|
|
"bread-emit bread.git.branch.changed --source git --kind branch.changed --data \
|
|
\"{\\\"repo\\\":\\\"$(git rev-parse --show-toplevel)\\\",\\\"branch\\\":\\\"$(git rev-parse --abbrev-ref HEAD)\\\",\\\"previous_ref\\\":\\\"$1\\\"}\" >/dev/null 2>&1 &"
|
|
.to_string()
|
|
}
|
|
|
|
/// The single `bread-emit` invocation line appropriate for hook `name`,
|
|
/// suggested to users who already have their own script at that hook (so it
|
|
/// must stand alone, including its own guard where relevant).
|
|
fn emit_line_for(name: &str) -> String {
|
|
match name {
|
|
"post-checkout" => format!("[ \"$3\" = \"1\" ] && {}", branch_changed_emit_line()),
|
|
_ => commit_created_emit_line(),
|
|
}
|
|
}
|
|
|
|
/// The `bread-emit` invocation shared by `post-commit` and `post-merge`
|
|
/// (both are "HEAD moved to a new commit" signals).
|
|
fn commit_created_emit_line() -> String {
|
|
"bread-emit bread.git.commit.created --source git --kind commit.created --data \
|
|
\"{\\\"repo\\\":\\\"$(git rev-parse --show-toplevel)\\\",\\\"sha\\\":\\\"$(git rev-parse HEAD)\\\",\\\"branch\\\":\\\"$(git rev-parse --abbrev-ref HEAD)\\\",\\\"message\\\":\\\"$(git log -1 --pretty=%s | sed 's/\"/\\\\\\\\\"/g')\\\"}\" >/dev/null 2>&1 &"
|
|
.to_string()
|
|
}
|
|
|
|
/// Build the full contents of the hook script for hook `name`.
|
|
///
|
|
/// Every script: starts with the marker (so future installs recognize it as
|
|
/// ours), backgrounds the `bread-emit` call so a slow/down daemon can never
|
|
/// delay the git operation, and unconditionally exits 0 so bread being
|
|
/// unavailable can never fail a `git commit`/`checkout`/`merge` for the user.
|
|
fn hook_script(name: &str) -> String {
|
|
match name {
|
|
"post-commit" => format!(
|
|
"#!/bin/sh\n{marker}\n# Emits bread.git.commit.created on every commit. Backgrounded and\n\
|
|
# always exits 0 so bread can never slow down or block `git commit`.\n\
|
|
{emit}\nexit 0\n",
|
|
marker = MARKER,
|
|
emit = commit_created_emit_line(),
|
|
),
|
|
"post-checkout" => format!(
|
|
"#!/bin/sh\n{marker}\n# git passes: $1=previous HEAD, $2=new HEAD, $3=1 if a branch\n\
|
|
# checkout (0 for a plain file checkout). Only emit on real branch\n\
|
|
# switches. previous_branch is not resolvable from a ref alone here,\n\
|
|
# so we report the previous HEAD's raw SHA ($1) as previous_ref instead\n\
|
|
# of a branch name.\n\
|
|
if [ \"$3\" = \"1\" ]; then\n {emit}\nfi\nexit 0\n",
|
|
marker = MARKER,
|
|
emit = branch_changed_emit_line(),
|
|
),
|
|
"post-merge" => format!(
|
|
"#!/bin/sh\n{marker}\n# A merge moves HEAD to a new commit, same as post-commit; emit the\n\
|
|
# same bread.git.commit.created shape so a merge (fast-forward or not)\n\
|
|
# also surfaces as a commit-created event. Backgrounded and always\n\
|
|
# exits 0 so bread can never slow down or block `git merge`.\n\
|
|
{emit}\nexit 0\n",
|
|
marker = MARKER,
|
|
emit = commit_created_emit_line(),
|
|
),
|
|
other => unreachable!("unknown hook name: {other}"),
|
|
}
|
|
}
|
|
|
|
/// `git rev-parse --git-dir`, resolved to an absolute path. This is the
|
|
/// correct git directory even inside worktrees or submodules (unlike
|
|
/// hardcoding `.git`).
|
|
fn git_dir() -> Result<PathBuf> {
|
|
let out = run_git(&["rev-parse", "--git-dir"]).context(
|
|
"bread: this does not look like a git repository. Run 'bread hooks install git' from \
|
|
inside a git work tree.",
|
|
)?;
|
|
let raw = PathBuf::from(out);
|
|
if raw.is_absolute() {
|
|
Ok(raw)
|
|
} else {
|
|
// `--git-dir` is often relative to CWD (e.g. ".git"); resolve it.
|
|
std::env::current_dir()
|
|
.map(|cwd| cwd.join(raw))
|
|
.context("failed to resolve current directory")
|
|
}
|
|
}
|
|
|
|
/// `git rev-parse --show-toplevel` — the repo root, used only for display.
|
|
fn show_toplevel() -> Result<PathBuf> {
|
|
run_git(&["rev-parse", "--show-toplevel"])
|
|
.map(PathBuf::from)
|
|
.context(
|
|
"bread: this does not look like a git repository. Run 'bread hooks install git' \
|
|
from inside a git work tree.",
|
|
)
|
|
}
|
|
|
|
/// `git config --get core.hooksPath`, if set to something non-default.
|
|
/// Returns `Ok(None)` when unset (the common case).
|
|
fn hooks_path_override() -> Result<Option<String>> {
|
|
let output = Command::new("git")
|
|
.args(["config", "--get", "core.hooksPath"])
|
|
.output()
|
|
.context("failed to run 'git config --get core.hooksPath' (is git installed?)")?;
|
|
|
|
if !output.status.success() {
|
|
// Exit code 1 from `git config --get` means "key not set" — that's
|
|
// the normal, expected case, not an error.
|
|
return Ok(None);
|
|
}
|
|
|
|
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if value.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(value))
|
|
}
|
|
}
|
|
|
|
fn print_hooks_path_warning(configured: &str) {
|
|
eprintln!(
|
|
"bread: this repo has 'core.hooksPath' set to '{configured}', so hooks placed in \
|
|
'.git/hooks' will never run.\n\
|
|
bread will not silently write into '.git/hooks' where they'd be dead code, and it \
|
|
will not write into your configured hooksPath without being asked to.\n\n\
|
|
To proceed, either:\n\
|
|
\x20 - point core.hooksPath back at the default: git config --unset core.hooksPath\n\
|
|
\x20 (or set it explicitly to .git/hooks), then re-run this command; or\n\
|
|
\x20 - install the three hook scripts into '{configured}' yourself (see \
|
|
`bread hooks install git --help` for the exact script contents this command \
|
|
would otherwise write).\n"
|
|
);
|
|
}
|
|
|
|
fn print_summary(toplevel: &Path, installed: &[PathBuf], skipped: &[PathBuf]) {
|
|
println!("bread: git hooks for {}", toplevel.display());
|
|
if installed.is_empty() {
|
|
println!(" installed: (none)");
|
|
} else {
|
|
println!(" installed:");
|
|
for path in installed {
|
|
println!(" {}", path.display());
|
|
}
|
|
}
|
|
if !skipped.is_empty() {
|
|
println!(" skipped (already exist, not bread-managed):");
|
|
for path in skipped {
|
|
println!(" {}", path.display());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run `git <args>` in the current directory and return trimmed stdout.
|
|
/// Fails if git is missing or the command exits non-zero.
|
|
fn run_git(args: &[&str]) -> Result<String> {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.output()
|
|
.with_context(|| format!("failed to run 'git {}' (is git installed?)", args.join(" ")))?;
|
|
|
|
if !output.status.success() {
|
|
bail!("'git {}' failed", args.join(" "));
|
|
}
|
|
|
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn detects_own_marker() {
|
|
let script = format!("#!/bin/sh\n{MARKER}\necho hi\n");
|
|
assert!(is_bread_managed(&script));
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_falsely_detect_marker() {
|
|
let script = "#!/bin/sh\n# some other tool's hook\necho hi\n";
|
|
assert!(!is_bread_managed(script));
|
|
}
|
|
|
|
#[test]
|
|
fn marker_must_match_whole_trimmed_line() {
|
|
// A substring match would be a false positive risk; require the
|
|
// trimmed line to equal the marker exactly.
|
|
let script = "#!/bin/sh\n# this mentions bread-managed-hook in passing\n";
|
|
assert!(!is_bread_managed(script));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_file_is_not_managed() {
|
|
assert!(!is_bread_managed(""));
|
|
}
|
|
|
|
#[test]
|
|
fn all_hook_scripts_start_with_shebang_and_marker() {
|
|
for name in HOOK_NAMES {
|
|
let script = hook_script(name);
|
|
let mut lines = script.lines();
|
|
assert_eq!(lines.next(), Some("#!/bin/sh"));
|
|
assert_eq!(lines.next(), Some(MARKER));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn all_hook_scripts_exit_0_unconditionally() {
|
|
for name in HOOK_NAMES {
|
|
let script = hook_script(name);
|
|
assert!(
|
|
script.trim_end().ends_with("exit 0"),
|
|
"hook {name} does not unconditionally exit 0"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn post_commit_and_post_merge_emit_commit_created() {
|
|
for name in ["post-commit", "post-merge"] {
|
|
let script = hook_script(name);
|
|
assert!(script.contains("bread.git.commit.created"));
|
|
assert!(script.contains("--kind commit.created"));
|
|
// Must be backgrounded so a slow/down daemon can't block git.
|
|
assert!(script.contains("&\nexit 0") || script.contains(" &\n"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn post_checkout_only_emits_on_branch_checkout() {
|
|
let script = hook_script("post-checkout");
|
|
assert!(script.contains("bread.git.branch.changed"));
|
|
assert!(script.contains("--kind branch.changed"));
|
|
assert!(script.contains("\"$3\" = \"1\""));
|
|
}
|
|
|
|
#[test]
|
|
fn hook_script_rejects_unknown_name() {
|
|
let result = std::panic::catch_unwind(|| hook_script("pre-push"));
|
|
assert!(result.is_err());
|
|
}
|
|
}
|