Add filesystem/git/podman/systemd adapters, git/shell hooks, bread-emit CLI, app-detection helpers

This commit is contained in:
Breadway 2026-07-19 03:05:57 +08:00
parent 89c5849539
commit 1208c5d1b7
29 changed files with 4098 additions and 339 deletions

357
bread-cli/src/hooks_git.rs Normal file
View file

@ -0,0 +1,357 @@
//! `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());
}
}

View file

@ -0,0 +1,512 @@
//! `bread hooks install shell` — generates a shell hook script that reports
//! terminal telemetry (command start/finish, cwd changes, remote-session
//! start/end) to the bread daemon.
//!
//! # Why this exists as a generated file, not an rc-file edit
//!
//! This module deliberately **never touches the user's `.bashrc`, `.zshrc`,
//! or fish `config.fish`.** It only writes self-contained hook script(s)
//! under `~/.config/bread/hooks/` and prints the one-line `source` snippet
//! the user needs to add themselves. Editing a user's shell startup file
//! automatically is the kind of silent, hard-to-audit change that belongs
//! to the user, not to a CLI subcommand — if `bread` got it wrong, or the
//! user later doesn't want it, an rc-file edit is much harder to notice and
//! undo than a printed snippet they chose to paste in.
//!
//! # Why bread-emit, not `bread emit`
//!
//! The generated hooks shell out to the separate `bread-emit` binary
//! (`bread-emit/src/main.rs`), not `bread emit`. `bread-emit` skips clap
//! parsing and the Tokio runtime entirely and never waits for a reply — it
//! is cheap enough to call on every single shell prompt. The full `bread`
//! CLI spins up an async runtime per invocation and would be perceptible
//! latency if called twice per prompt.
//!
//! # Why both a `.sh` and a `.fish` file are always written
//!
//! `install_shell` always regenerates both `shell-hook.sh` (bash + zsh, a
//! single file with an `if [ -n "$ZSH_VERSION" ]; ... elif [ -n
//! "$BASH_VERSION" ]; ...` branch) and `shell-hook.fish` (fish has
//! meaningfully different hook primitives and cannot source a POSIX-ish
//! script anyway), regardless of which shell was detected or requested.
//! This keeps both files present and up to date at fixed, predictable
//! paths so a user who switches shells later doesn't need to re-run
//! install — they just add the one extra `source` line to the new shell's
//! rc file. Only the *printed* snippet is specific to the detected/forced
//! shell.
//!
//! Existing hook files at these paths are always overwritten. Unlike git
//! hooks, there is no ecosystem of third-party `shell-hook.sh` files a user
//! might have installed by other means — this file is entirely owned and
//! generated by this command, so overwriting on every run is safe and is
//! in fact required to pick up script changes across `bread` upgrades.
use anyhow::{bail, Result};
use std::fs;
use std::path::PathBuf;
/// Which shell to print the `source` snippet for. Detected from `$SHELL`
/// unless the caller forces one via `bread hooks install shell --shell
/// <name>`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetShell {
Bash,
Zsh,
Fish,
/// Detection failed (unrecognized or unset `$SHELL`); both snippets are
/// printed and the user picks the one that matches their shell.
Unknown,
}
impl TargetShell {
fn parse(name: &str) -> Result<TargetShell> {
match name {
"bash" => Ok(TargetShell::Bash),
"zsh" => Ok(TargetShell::Zsh),
"fish" => Ok(TargetShell::Fish),
other => bail!(
"bread: unrecognized shell '{}' (expected one of: bash, zsh, fish)",
other
),
}
}
/// Detect the caller's shell from `$SHELL`'s basename. This is a best
/// effort: `$SHELL` reflects the user's login shell, which is normally
/// also the shell running the CLI, but that's not guaranteed (e.g.
/// invoked from inside a script run under a different interpreter).
fn detect_from_env() -> TargetShell {
let Ok(shell_path) = std::env::var("SHELL") else {
return TargetShell::Unknown;
};
let name = PathBuf::from(shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
match name.as_str() {
"bash" => TargetShell::Bash,
"zsh" => TargetShell::Zsh,
"fish" => TargetShell::Fish,
_ => TargetShell::Unknown,
}
}
}
/// Returns the default hooks directory: `~/.config/bread/hooks`.
pub fn hooks_dir() -> PathBuf {
if let Some(cfg) = dirs::config_dir() {
return cfg.join("bread").join("hooks");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home)
.join(".config")
.join("bread")
.join("hooks");
}
PathBuf::from(".config/bread/hooks")
}
/// Install (or regenerate) the shell hook scripts and print the snippet the
/// user needs to add to their own rc file. `shell` optionally forces
/// bash/zsh/fish; otherwise the target shell is auto-detected from `$SHELL`
/// purely to decide which snippet to print — both hook files are written
/// either way (see module docs).
pub fn install_shell(shell: Option<String>) -> Result<()> {
let target = match shell {
Some(name) => TargetShell::parse(&name)?,
None => TargetShell::detect_from_env(),
};
let dir = hooks_dir();
fs::create_dir_all(&dir)
.map_err(|e| anyhow::anyhow!("failed to create {}: {}", dir.display(), e))?;
let sh_path = dir.join("shell-hook.sh");
write_hook_file(&sh_path, BASH_ZSH_HOOK_SCRIPT)?;
let fish_path = dir.join("shell-hook.fish");
write_hook_file(&fish_path, FISH_HOOK_SCRIPT)?;
println!();
match target {
TargetShell::Bash | TargetShell::Zsh => {
println!("Add this line to your ~/.bashrc or ~/.zshrc:");
println!();
println!(" source {}", sh_path.display());
}
TargetShell::Fish => {
println!("Add this line to your ~/.config/fish/config.fish:");
println!();
println!(" source {}", fish_path.display());
}
TargetShell::Unknown => {
println!(
"bread: could not detect your shell from $SHELL; add whichever \
of these matches the shell you use interactively:"
);
println!();
println!(" bash/zsh — add to ~/.bashrc or ~/.zshrc:");
println!(" source {}", sh_path.display());
println!();
println!(" fish — add to ~/.config/fish/config.fish:");
println!(" source {}", fish_path.display());
}
}
println!();
println!(
"bread never edits rc files on its own — that line above is something \
only you should add."
);
Ok(())
}
fn write_hook_file(path: &PathBuf, contents: &str) -> Result<()> {
let existed = path.exists();
fs::write(path, contents)
.map_err(|e| anyhow::anyhow!("failed to write {}: {}", path.display(), e))?;
if existed {
println!("bread: regenerated {}", path.display());
} else {
println!("bread: wrote {}", path.display());
}
Ok(())
}
// ---------------------------------------------------------------------------
// Hook script bodies
// ---------------------------------------------------------------------------
//
// Both scripts below shell out to `bread-emit` (not `bread emit`) for every
// event, always backgrounded and with output discarded (`>/dev/null 2>&1
// &`), so a down, slow, or stalled daemon can never block the interactive
// shell waiting for the next prompt.
//
// Event/kind pairs emitted (kept in sync with the daemon's normalizer):
// bread.terminal.command.started (kind command.started)
// bread.terminal.command.finished (kind command.finished)
// bread.terminal.cwd.changed (kind cwd.changed)
// bread.remote.session.started (kind session.started)
// bread.remote.session.ended (kind session.ended)
//
// `bread-emit`'s positional <event> argument is technically redundant once
// --source/--kind are both given (the daemon derives the real event name
// from source+kind and ignores the literal positional string in that
// mode), but a sensible name is still passed since bread-emit requires a
// positional argument.
/// Combined bash + zsh hook script written to `~/.config/bread/hooks/shell-hook.sh`.
///
/// zsh has native `preexec`/`precmd`/`chpwd` hooks; bash has none of the
/// three, so its branch approximates them with a `trap ... DEBUG` (guarded
/// so it only fires once per prompt, not once per pipeline stage) and a
/// `PROMPT_COMMAND` that both plays the precmd role and polls `$PWD` against
/// a remembered previous value to emulate `chpwd`.
const BASH_ZSH_HOOK_SCRIPT: &str = r#"# bread shell hook — generated by `bread hooks install shell`.
# Do not hand-edit; rerun `bread hooks install shell` to regenerate.
#
# Reports terminal telemetry (command start/finish, cwd changes, remote
# session start/end) to the bread daemon via the lightweight `bread-emit`
# binary. Every call below is backgrounded and has its output discarded so a
# down or slow daemon can never delay the shell prompt.
#
# Assumes GNU date (for `date +%s%3N`, millisecond epoch) and GNU sed (for
# the JSON-escaping helper below) both are standard on Arch Linux.
# Escape a string for embedding inside a JSON string literal: backslashes,
# double quotes, tabs, carriage returns, and embedded newlines. Backslashes
# must be escaped first, before any escape sequence that introduces new
# backslashes, or the newly-added backslashes would themselves get doubled.
_bread_json_escape() {
printf '%s' "$1" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' \
| sed ':a;N;$!ba;s/\n/\\n/g'
}
# --- Remote session detection (runs once at shell-init time) --------------
# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported
# so nested subshells (and the EXIT trap that fires only in this branch)
# don't each fire their own started/ended pair.
if { [ -n "$SSH_TTY" ] || [ -n "$SSH_CONNECTION" ]; } && [ -z "$BREAD_SSH_ANNOUNCED" ]; then
export BREAD_SSH_ANNOUNCED=1
# SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall
# back to SSH_CLIENT (same leading field) if it's unset for some reason.
_bread_ssh_host=$(printf '%s' "${SSH_CONNECTION:-$SSH_CLIENT}" | awk '{print $1}')
_bread_esc_host=$(_bread_json_escape "$_bread_ssh_host")
bread-emit bread.remote.session.started --source remote --kind session.started \
--data "{\"host\":\"$_bread_esc_host\"}" >/dev/null 2>&1 &
_bread_ssh_exit_hook() {
_bread_esc_host_exit=$(_bread_json_escape "$_bread_ssh_host")
bread-emit bread.remote.session.ended --source remote --kind session.ended \
--data "{\"host\":\"$_bread_esc_host_exit\"}" >/dev/null 2>&1 &
}
trap _bread_ssh_exit_hook EXIT
fi
if [ -n "$ZSH_VERSION" ]; then
# --- zsh: preexec / precmd / chpwd are native ---------------------------
autoload -Uz add-zsh-hook
_bread_preexec() {
_bread_cmd_start=$(date +%s%3N)
_bread_cmd_cwd="$PWD"
_bread_cmd_str="$1"
local esc_cmd esc_cwd
esc_cmd=$(_bread_json_escape "$1")
esc_cwd=$(_bread_json_escape "$PWD")
bread-emit bread.terminal.command.started --source terminal --kind command.started \
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 &
}
add-zsh-hook preexec _bread_preexec
_bread_precmd() {
local exit_code=$?
if [ -n "$_bread_cmd_str" ]; then
local now duration_ms esc_cmd esc_cwd
now=$(date +%s%3N)
duration_ms=$(( now - _bread_cmd_start ))
esc_cmd=$(_bread_json_escape "$_bread_cmd_str")
esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd")
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 &
_bread_cmd_str=""
fi
}
add-zsh-hook precmd _bread_precmd
_bread_chpwd() {
local esc_cwd esc_prev
esc_cwd=$(_bread_json_escape "$PWD")
esc_prev=$(_bread_json_escape "${OLDPWD:-$PWD}")
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
--data "{\"cwd\":\"$esc_cwd\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 &
}
add-zsh-hook chpwd _bread_chpwd
elif [ -n "$BASH_VERSION" ]; then
# --- bash: no native preexec/precmd/chpwd; approximate them -------------
# trap ... DEBUG fires before every simple command. `_bread_preexec_guard`
# ensures only the first simple command between prompts is recorded, not
# every stage of a pipeline or every `;`-separated command on one line.
# This relies on bash's default of NOT propagating the DEBUG trap into
# function calls (i.e. `set -o functrace`/`shopt -s extdebug` are off);
# if something in the user's environment turns those on, the guard below
# could fire more than once per prompt.
_bread_preexec() {
[ -n "$_bread_preexec_guard" ] && return
case "$BASH_COMMAND" in
_bread_precmd*|"$PROMPT_COMMAND") return ;;
esac
_bread_preexec_guard=1
_bread_cmd_start=$(date +%s%3N)
_bread_cmd_cwd="$PWD"
_bread_cmd_str="$BASH_COMMAND"
local esc_cmd esc_cwd
esc_cmd=$(_bread_json_escape "$BASH_COMMAND")
esc_cwd=$(_bread_json_escape "$PWD")
bread-emit bread.terminal.command.started --source terminal --kind command.started \
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\"}" >/dev/null 2>&1 &
}
trap '_bread_preexec' DEBUG
_bread_precmd() {
local exit_code=$?
if [ -n "$_bread_preexec_guard" ]; then
local now duration_ms esc_cmd esc_cwd
now=$(date +%s%3N)
duration_ms=$(( now - _bread_cmd_start ))
esc_cmd=$(_bread_json_escape "$_bread_cmd_str")
esc_cwd=$(_bread_json_escape "$_bread_cmd_cwd")
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
--data "{\"cmd\":\"$esc_cmd\",\"cwd\":\"$esc_cwd\",\"exit_code\":$exit_code,\"duration_ms\":$duration_ms}" >/dev/null 2>&1 &
fi
_bread_preexec_guard=""
# chpwd emulation: bash has no native hook, so compare $PWD against a
# remembered previous value on every prompt.
if [ "$PWD" != "${_bread_prev_pwd:-$PWD}" ]; then
local esc_cwd2 esc_prev
esc_cwd2=$(_bread_json_escape "$PWD")
esc_prev=$(_bread_json_escape "${_bread_prev_pwd:-$PWD}")
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
--data "{\"cwd\":\"$esc_cwd2\",\"prev_cwd\":\"$esc_prev\"}" >/dev/null 2>&1 &
_bread_prev_pwd="$PWD"
fi
}
PROMPT_COMMAND="_bread_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
_bread_prev_pwd="$PWD"
fi
"#;
/// Fish hook script written to `~/.config/bread/hooks/shell-hook.fish`.
///
/// Fish's job-control primitives are meaningfully nicer than bash/zsh here:
/// `fish_preexec`/`fish_postexec` are real events (no DEBUG-trap games),
/// `$status` in the postexec handler gives the exit code directly, and
/// `$CMD_DURATION` is already a builtin millisecond duration — no manual
/// timestamp math needed. `string escape --style=json` (fish >= 3.3) does
/// the JSON-string-literal escaping, quotes included, in one call.
const FISH_HOOK_SCRIPT: &str = r#"# bread shell hook (fish) — generated by `bread hooks install shell`.
# Do not hand-edit; rerun `bread hooks install shell` to regenerate.
#
# Reports terminal telemetry (command start/finish, cwd changes, remote
# session start/end) to the bread daemon via the lightweight `bread-emit`
# binary. Every call below is backgrounded and has its output discarded so a
# down or slow daemon can never delay the shell prompt.
#
# Requires fish >= 3.3 for `string escape --style=json`.
function _bread_json_escape --description 'Escape a value as a JSON string literal (surrounding quotes included)'
string escape --style=json -- $argv[1]
end
function _bread_preexec --on-event fish_preexec --description 'bread: emit command.started'
set -g _bread_cmd_cwd $PWD
set -l cmd_json (_bread_json_escape $argv[1])
set -l cwd_json (_bread_json_escape $PWD)
bread-emit bread.terminal.command.started --source terminal --kind command.started \
--data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json}" >/dev/null 2>&1 &
end
function _bread_postexec --on-event fish_postexec --description 'bread: emit command.finished'
# $status must be captured first, before any other command in this
# function has a chance to overwrite it.
set -l exit_code $status
set -l cmd_json (_bread_json_escape $argv[1])
set -l cwd_json (_bread_json_escape $_bread_cmd_cwd)
bread-emit bread.terminal.command.finished --source terminal --kind command.finished \
--data "{\"cmd\": $cmd_json, \"cwd\": $cwd_json, \"exit_code\": $exit_code, \"duration_ms\": $CMD_DURATION}" >/dev/null 2>&1 &
end
function _bread_pwd_changed --on-variable PWD --description 'bread: emit cwd.changed'
status is-interactive; or return
set -l cwd_json (_bread_json_escape $PWD)
set -l prev_json (_bread_json_escape $_bread_prev_pwd)
bread-emit bread.terminal.cwd.changed --source terminal --kind cwd.changed \
--data "{\"cwd\": $cwd_json, \"prev_cwd\": $prev_json}" >/dev/null 2>&1 &
set -g _bread_prev_pwd $PWD
end
set -g _bread_prev_pwd $PWD
# --- Remote session detection (runs once at shell-init time) --------------
# Only the outermost login shell announces: BREAD_SSH_ANNOUNCED is exported
# so nested fish subshells (and the fish_exit handler, which is only
# registered in this branch) don't each fire their own started/ended pair.
set -l _bread_is_ssh 0
if test -n "$SSH_TTY"
set _bread_is_ssh 1
else if test -n "$SSH_CONNECTION"
set _bread_is_ssh 1
end
if test $_bread_is_ssh -eq 1; and test -z "$BREAD_SSH_ANNOUNCED"
set -gx BREAD_SSH_ANNOUNCED 1
# SSH_CONNECTION is "client_ip client_port server_ip server_port"; fall
# back to SSH_CLIENT (same leading field) if it's unset for some reason.
set -l _bread_conn_str $SSH_CONNECTION
if test -z "$_bread_conn_str"
set _bread_conn_str $SSH_CLIENT
end
set -g _bread_ssh_host (string split ' ' -- $_bread_conn_str)[1]
set -l host_json (_bread_json_escape $_bread_ssh_host)
bread-emit bread.remote.session.started --source remote --kind session.started \
--data "{\"host\": $host_json}" >/dev/null 2>&1 &
function _bread_ssh_exit_hook --on-event fish_exit --description 'bread: emit remote session.ended'
set -l host_json (_bread_json_escape $_bread_ssh_host)
bread-emit bread.remote.session.ended --source remote --kind session.ended \
--data "{\"host\": $host_json}" >/dev/null 2>&1 &
end
end
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_known_shells() {
assert_eq!(TargetShell::parse("bash").unwrap(), TargetShell::Bash);
assert_eq!(TargetShell::parse("zsh").unwrap(), TargetShell::Zsh);
assert_eq!(TargetShell::parse("fish").unwrap(), TargetShell::Fish);
}
#[test]
fn parse_rejects_unknown_shell() {
assert!(TargetShell::parse("powershell").is_err());
assert!(TargetShell::parse("").is_err());
}
#[test]
fn detect_from_env_reads_shell_basename() {
std::env::set_var("SHELL", "/usr/bin/zsh");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Zsh);
std::env::set_var("SHELL", "/bin/bash");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Bash);
std::env::set_var("SHELL", "/usr/bin/fish");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Fish);
std::env::set_var("SHELL", "/usr/bin/tcsh");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown);
std::env::remove_var("SHELL");
assert_eq!(TargetShell::detect_from_env(), TargetShell::Unknown);
}
#[test]
fn hook_scripts_reference_bread_emit_not_bread_emit_cli() {
// Guard against accidentally shelling out to the slow `bread emit`
// subcommand instead of the lightweight `bread-emit` binary.
for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] {
assert!(script.contains("bread-emit "));
assert!(!script.contains("bread emit "));
}
}
#[test]
fn hook_scripts_background_every_emit_call() {
// Every bread-emit invocation must be backgrounded with output
// discarded so a stalled daemon can never block the shell. A single
// invocation can span multiple physical lines via a trailing `\`
// continuation, so continuation lines are joined into one logical
// statement before checking, rather than inspecting each line in
// isolation.
for script in [BASH_ZSH_HOOK_SCRIPT, FISH_HOOK_SCRIPT] {
for statement in join_line_continuations(script) {
if statement.contains("bread-emit ") {
assert!(
statement.contains(">/dev/null 2>&1 &"),
"statement not backgrounded/discarded: {statement}"
);
}
}
}
}
/// Joins physical lines ending in a trailing `\` continuation into a
/// single logical statement, so multi-line shell invocations can be
/// checked as a whole rather than line-by-line.
fn join_line_continuations(script: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current = String::new();
for line in script.lines() {
let trimmed_end = line.trim_end();
if let Some(rest) = trimmed_end.strip_suffix('\\') {
current.push_str(rest);
current.push(' ');
} else {
current.push_str(trimmed_end);
statements.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
statements.push(current);
}
statements
}
}

View file

@ -1,3 +1,5 @@
mod hooks_git;
mod hooks_shell;
mod modules_mgmt;
use anyhow::Result;
@ -58,6 +60,11 @@ enum Commands {
#[command(subcommand)]
subcommand: ModulesCommand,
},
/// Install shell/git hook integrations that feed events into breadd
Hooks {
#[command(subcommand)]
subcommand: HooksCommand,
},
/// List available profiles
ProfileList,
/// Activate a profile
@ -67,6 +74,13 @@ enum Commands {
event: String,
#[arg(short, long, default_value = "{}")]
data: String,
/// Source to tag the event with (terminal/git/remote); routes through
/// the normalizer instead of being tagged System. Requires --kind.
#[arg(long)]
source: Option<String>,
/// Adapter-specific raw kind (e.g. "command.started"); only used with --source.
#[arg(long)]
kind: Option<String>,
},
/// Health check daemon connectivity
Ping,
@ -80,6 +94,19 @@ enum Commands {
},
}
#[derive(Subcommand, Debug)]
enum HooksCommand {
/// Install shell integration hooks (precmd/preexec/chpwd + SSH session
/// detection) for the current or a named shell
InstallShell {
/// Force bash/zsh/fish instead of auto-detecting from $SHELL
shell: Option<String>,
},
/// Install git hooks (post-commit, post-checkout, post-merge) into the
/// current repository
InstallGit,
}
#[derive(Subcommand, Debug)]
enum ModulesCommand {
/// Install a module from a local directory
@ -137,6 +164,10 @@ async fn main() -> Result<()> {
Commands::Modules { subcommand } => {
handle_modules_cmd(subcommand, &socket).await?;
}
Commands::Hooks { subcommand } => match subcommand {
HooksCommand::InstallShell { shell } => hooks_shell::install_shell(shell)?,
HooksCommand::InstallGit => hooks_git::install_git()?,
},
Commands::ProfileList => {
let response = send_request(&socket, "profile.list", json!({})).await?;
print_json(&response)?;
@ -146,17 +177,24 @@ async fn main() -> Result<()> {
send_request(&socket, "profile.activate", json!({ "name": name })).await?;
print_json(&response)?;
}
Commands::Emit { event, data } => {
Commands::Emit {
event,
data,
source,
kind,
} => {
let parsed = serde_json::from_str::<Value>(&data).unwrap_or_else(|_| json!({}));
let response = send_request(
&socket,
"emit",
json!({
"event": event,
"data": parsed,
}),
)
.await?;
let mut params = json!({
"event": event,
"data": parsed,
});
if let Some(source) = source {
params["source"] = json!(source);
}
if let Some(kind) = kind {
params["kind"] = json!(kind);
}
let response = send_request(&socket, "emit", params).await?;
print_json(&response)?;
}
Commands::Ping => {