From e86f2012ca8258cdc8cd792fd0dfcc5022838b76 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 10 Aug 2026 19:09:54 -0500 Subject: [PATCH 01/20] feat(lock): add commit-blocking lock/unlock with pre-commit hook --- docs/cli-reference.md | 13 + docs/index.md | 3 + docs/lock.md | 45 ++ src/hooks/mod.rs | 6 +- src/lock/mod.rs | 932 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 7 + tests/integration.rs | 198 +++++++++ 7 files changed, 1201 insertions(+), 3 deletions(-) create mode 100644 docs/lock.md create mode 100644 src/lock/mod.rs diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ac23f74..5ce2e76 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -32,6 +32,19 @@ Running `gitkit` with no command starts the interactive wizard. | `gitkit hooks remove ` | Remove an installed hook | | `gitkit hooks show ` | Print hook content | +## Lock + +| Command | Description | +|---|---| +| `gitkit lock` | Block commits until `gitkit unlock` | +| `gitkit lock --reason ` | Set the message shown on a blocked commit | +| `gitkit lock --timeout ` | Auto-expire the lock, e.g. `30m`, `2h` | +| `gitkit lock status` | Show whether a lock is active, its reason and expiry | +| `gitkit unlock` | Remove the lock and restore any backed-up hook | + +`git commit --no-verify` bypasses the lock — see [Lock](lock.md) for why +that is accepted rather than defended against. + ## Ignore | Command | Description | diff --git a/docs/index.md b/docs/index.md index b23e971..56f5209 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,6 +28,8 @@ project with one command. into the wizard. - **Hook management** — built-in hooks (conventional commits, secret detection, branch naming) or your own shell command. +- **Agent lock** — block commits locally and reversibly for the + duration of an agent session with `gitkit lock`. - **Ignore & attribute presets** — all gitignore.io templates plus built-ins, line-ending and binary presets. - **Curated git config** — practical presets with `--global`/`--local` @@ -39,6 +41,7 @@ project with one command. - [Installation](installation.md) — install, update and uninstall. - [Quick Start](quickstart.md) — the wizard and the one-liner workflow. - [Hooks](hooks.md) — built-in and custom hooks. +- [Lock](lock.md) — block commits for an agent session, and its limits. - [Ignore & Attributes](ignore-and-attributes.md) — `.gitignore` and `.gitattributes`. - [Config Presets](config-presets.md) — curated git config, scopes, idempotency. - [Builds](builds.md) — save and reuse configurations. diff --git a/docs/lock.md b/docs/lock.md new file mode 100644 index 0000000..c3d790b --- /dev/null +++ b/docs/lock.md @@ -0,0 +1,45 @@ +--- +title: Lock +description: Block commits locally and reversibly for the duration of an agent session. +order: 5 +--- + +# Lock + +`gitkit lock` lets a human stop an AI agent (or anyone else) from +committing to a repository, locally, for the duration of a session. + +```bash +gitkit lock # block commits until `gitkit unlock` +gitkit lock --reason "Agent session" # custom message shown on a blocked commit +gitkit lock --timeout 30m # auto-expires after 30 minutes +gitkit lock status # show whether a lock is active +gitkit unlock # remove the lock +``` + +Locking twice updates the existing lock (reason, timeout) instead of +stacking or erroring. + +## How it works + +`gitkit lock` writes a small JSON state file at `.git/gitkit.lock` and +installs a `pre-commit` hook that reads it. The hook is pure POSIX `sh` — +no dependency on the `gitkit` binary — so it stays fast on every commit. +A missing, empty, or malformed lock file is always treated as unlocked: +a corrupt lock never blocks a commit. + +If you already had a `pre-commit` hook, it is backed up to +`pre-commit.gitkit-orig` and chained to — it still runs after the lock +check passes. `gitkit unlock` restores it and removes the backup. + +The lock is per-repository, local only, and never committed or pushed — +it lives entirely under `.git/`. + +## Limitation: `--no-verify` + +`git commit --no-verify` bypasses all pre-commit hooks, including this +one. **This is expected and not treated as a bug.** The lock's threat +model is an AI agent following its instructions, not a human deliberately +working around a local safeguard — so no attempt is made to defend +against `--no-verify`. If you need a guarantee that survives a +determined bypass, this is not that guarantee. diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index b386e37..5932c56 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -89,7 +89,7 @@ pub(crate) fn detect_builtin(hook_file: &str, content: &str) -> Option<&'static .find(|b| b.hook == hook_file && content.trim() == b.script.trim()) } -fn hooks_dir() -> Result { +pub(crate) fn hooks_dir() -> Result { Ok(find_repo_root()?.join(".git").join("hooks")) } @@ -240,7 +240,7 @@ fn show(hook: &str) -> Result<()> { } #[cfg(unix)] -fn set_executable(path: &Path) -> Result<()> { +pub(crate) fn set_executable(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(path)?.permissions(); perms.set_mode(0o755); @@ -249,7 +249,7 @@ fn set_executable(path: &Path) -> Result<()> { } #[cfg(not(unix))] -fn set_executable(_path: &Path) -> Result<()> { +pub(crate) fn set_executable(_path: &Path) -> Result<()> { Ok(()) } diff --git a/src/lock/mod.rs b/src/lock/mod.rs new file mode 100644 index 0000000..a674ee3 --- /dev/null +++ b/src/lock/mod.rs @@ -0,0 +1,932 @@ +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::hooks; +use crate::utils::find_repo_root; + +const LOCK_FILE_NAME: &str = "gitkit.lock"; +const HOOK_NAME: &str = "pre-commit"; +const BACKUP_SUFFIX: &str = "gitkit-orig"; +const DEFAULT_REASON: &str = "Agent session active"; + +/// The pre-commit hook gitkit installs. Pure POSIX `sh` — no dependency on the +/// `gitkit` binary or any JSON tooling, so it stays fast and has nothing new +/// to fail on the hot path. Reads `gitkit.lock` next to it, fails open on any +/// missing/malformed/expired lock, and chains to a backed-up user hook if any. +const LOCK_HOOK_SCRIPT: &str = r#"#!/bin/sh +# Installed by `gitkit lock`. Blocks commits while a lock is active. +# See `gitkit lock status` / `gitkit unlock`. Bypass with `git commit --no-verify`. + +git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0 +lock_file="$git_dir/gitkit.lock" +orig_hook="$git_dir/hooks/pre-commit.gitkit-orig" + +if [ -f "$lock_file" ]; then + ops=$(sed -n 's/.*"operations":\[\([^]]*\)\].*/\1/p' "$lock_file" 2>/dev/null) + if printf '%s' "$ops" | grep -qF '"commit"'; then + expires=$(sed -n 's/.*"expires_at":"\([^"]*\)".*/\1/p' "$lock_file" 2>/dev/null) + blocked=1 + if [ -n "$expires" ]; then + now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + if [ "$now" \> "$expires" ]; then + blocked=0 + fi + fi + if [ "$blocked" -eq 1 ]; then + reason=$(sed -n 's/.*"reason":"\([^"]*\)".*/\1/p' "$lock_file" 2>/dev/null) + echo "gitkit: commit blocked - ${reason:-Agent session active}" >&2 + echo "gitkit: run 'gitkit unlock' to remove the lock, or 'git commit --no-verify' to bypass it" >&2 + exit 1 + fi + fi +fi + +if [ -x "$orig_hook" ]; then + exec "$orig_hook" "$@" +fi + +exit 0 +"#; + +#[derive(Args)] +pub struct LockArgs { + #[command(subcommand)] + action: Option, + /// Auto-expire the lock after a duration, e.g. 30m, 2h, 1d + #[arg(long)] + timeout: Option, + /// Message shown when a blocked commit is attempted + #[arg(long)] + reason: Option, +} + +#[derive(Subcommand)] +enum LockAction { + /// Show whether a lock is currently active + Status, +} + +pub fn run(args: LockArgs) -> Result<()> { + match args.action { + Some(LockAction::Status) => status(), + None => lock(args.timeout.as_deref(), args.reason.as_deref()), + } +} + +pub fn unlock() -> Result<()> { + let root = find_repo_root()?; + let path = lock_file_path(&root); + if path.exists() { + fs::remove_file(&path).context("Failed to remove lock file")?; + } + uninstall_hook()?; + println!("Unlocked. Commits are no longer blocked by gitkit."); + Ok(()) +} + +fn lock(timeout: Option<&str>, reason: Option<&str>) -> Result<()> { + let root = find_repo_root()?; + + let expires_at = timeout + .map(parse_duration) + .transpose()? + .map(|secs| format_rfc3339(unix_now() + secs)); + + let lf = LockFile { + locked_at: format_rfc3339(unix_now()), + expires_at, + reason: reason.unwrap_or(DEFAULT_REASON).to_string(), + operations: vec!["commit".to_string()], + }; + + fs::write(lock_file_path(&root), lf.to_json()).context("Failed to write lock file")?; + install_hook().context("Failed to install pre-commit hook")?; + + println!("Locked: {}", lf.reason); + match &lf.expires_at { + Some(exp) => println!("Expires at: {exp}"), + None => println!("Expires at: never (until `gitkit unlock`)"), + } + Ok(()) +} + +fn status() -> Result<()> { + let root = find_repo_root()?; + let path = lock_file_path(&root); + + if !path.exists() { + println!("No lock active."); + return Ok(()); + } + + let content = fs::read_to_string(&path).context("Failed to read lock file")?; + let Some(lf) = LockFile::parse(&content) else { + println!("Lock file is malformed - treated as unlocked (commits are not blocked)."); + return Ok(()); + }; + + if !lf.operations.iter().any(|op| op == "commit") { + println!("No commit lock active."); + return Ok(()); + } + + let now = format_rfc3339(unix_now()); + println!("Locked: {}", lf.reason); + println!("Locked at: {}", lf.locked_at); + match &lf.expires_at { + Some(exp) if now.as_str() > exp.as_str() => { + println!("Expires at: {exp} (expired - commits are not blocked)"); + } + Some(exp) => println!("Expires at: {exp}"), + None => println!("Expires at: never"), + } + Ok(()) +} + +fn lock_file_path(root: &Path) -> PathBuf { + root.join(".git").join(LOCK_FILE_NAME) +} + +fn install_hook() -> Result<()> { + let dir = hooks::hooks_dir()?; + fs::create_dir_all(&dir).context("Failed to create hooks directory")?; + let hook_path = dir.join(HOOK_NAME); + let backup_path = dir.join(format!("{HOOK_NAME}.{BACKUP_SUFFIX}")); + + if hook_path.exists() { + let existing = fs::read_to_string(&hook_path).unwrap_or_default(); + if existing.trim() != LOCK_HOOK_SCRIPT.trim() { + fs::copy(&hook_path, &backup_path) + .context("Failed to back up existing pre-commit hook")?; + } + } + + fs::write(&hook_path, LOCK_HOOK_SCRIPT).context("Failed to write pre-commit hook")?; + hooks::set_executable(&hook_path)?; + Ok(()) +} + +/// Only touches the hook file if it's still the one gitkit installed — +/// never clobbers a hook that was manually replaced after locking. +fn uninstall_hook() -> Result<()> { + let dir = hooks::hooks_dir()?; + let hook_path = dir.join(HOOK_NAME); + let backup_path = dir.join(format!("{HOOK_NAME}.{BACKUP_SUFFIX}")); + + if !hook_path.exists() { + return Ok(()); + } + let existing = fs::read_to_string(&hook_path).unwrap_or_default(); + if existing.trim() != LOCK_HOOK_SCRIPT.trim() { + return Ok(()); + } + + if backup_path.exists() { + fs::rename(&backup_path, &hook_path) + .context("Failed to restore original pre-commit hook")?; + hooks::set_executable(&hook_path)?; + } else { + fs::remove_file(&hook_path).context("Failed to remove pre-commit hook")?; + } + Ok(()) +} + +// ── lock file model ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +struct LockFile { + locked_at: String, + expires_at: Option, + reason: String, + operations: Vec, +} + +impl LockFile { + /// Compact single-line JSON by design: keeps the pre-commit hook's shell + /// parsing (one `sed` pass per field) simple and fast. + fn to_json(&self) -> String { + let expires = match &self.expires_at { + Some(e) => format!("\"{}\"", escape(e)), + None => "null".to_string(), + }; + let ops = self + .operations + .iter() + .map(|o| format!("\"{}\"", escape(o))) + .collect::>() + .join(","); + format!( + "{{\"locked_at\":\"{}\",\"expires_at\":{},\"reason\":\"{}\",\"operations\":[{}]}}\n", + escape(&self.locked_at), + expires, + escape(&self.reason), + ops + ) + } + + fn parse(content: &str) -> Option { + let locked_at = extract_string(content, "locked_at")?; + let reason = extract_string(content, "reason").unwrap_or_default(); + let expires_at = extract_string(content, "expires_at"); + let operations = extract_array(content, "operations")?; + Some(LockFile { + locked_at, + expires_at, + reason, + operations, + }) + } +} + +fn extract_string(content: &str, key: &str) -> Option { + let needle = format!("\"{key}\":\""); + let start = content.find(&needle)? + needle.len(); + let rest = &content[start..]; + let mut end = None; + let mut escaped = false; + for (i, c) in rest.char_indices() { + if escaped { + escaped = false; + continue; + } + match c { + '\\' => escaped = true, + '"' => { + end = Some(i); + break; + } + _ => {} + } + } + Some(unescape(&rest[..end?])) +} + +fn extract_array(content: &str, key: &str) -> Option> { + let needle = format!("\"{key}\":["); + let start = content.find(&needle)? + needle.len(); + let rest = &content[start..]; + let end = rest.find(']')?; + let inner = &rest[..end]; + if inner.trim().is_empty() { + return Some(Vec::new()); + } + inner + .split(',') + .map(|s| { + let s = s.trim(); + let s = s.strip_prefix('"')?.strip_suffix('"')?; + Some(unescape(s)) + }) + .collect() +} + +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c => out.push(c), + } + } + out +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('t') => out.push('\t'), + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some(other) => out.push(other), + None => {} + } + } + out +} + +// ── duration & time helpers ────────────────────────────────────────────── + +fn parse_duration(s: &str) -> Result { + let s = s.trim(); + anyhow::ensure!(!s.is_empty(), "Duration cannot be empty"); + + let mut total: u64 = 0; + let mut num = String::new(); + let mut any_unit = false; + + for c in s.chars() { + if c.is_ascii_digit() { + num.push(c); + continue; + } + anyhow::ensure!( + !num.is_empty(), + "Invalid duration '{s}': expected a number before '{c}'" + ); + let n: u64 = num + .parse() + .with_context(|| format!("Invalid duration '{s}'"))?; + let mult: u64 = match c { + 's' => 1, + 'm' => 60, + 'h' => 3600, + 'd' => 86400, + _ => anyhow::bail!("Invalid duration unit '{c}' in '{s}' (use s, m, h, or d)"), + }; + total += n * mult; + num.clear(); + any_unit = true; + } + + anyhow::ensure!( + num.is_empty(), + "Invalid duration '{s}': trailing number with no unit" + ); + anyhow::ensure!( + any_unit, + "Invalid duration '{s}': missing unit (use s, m, h, or d)" + ); + Ok(total) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Howard Hinnant's `civil_from_days`: converts days since 1970-01-01 into a +/// proleptic-Gregorian (year, month, day). See +/// http://howardhinnant.github.io/date_algorithms.html +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +fn format_rfc3339(secs: u64) -> String { + let secs = secs as i64; + let days = secs.div_euclid(86400); + let rem = secs.rem_euclid(86400); + let (y, mo, d) = civil_from_days(days); + let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use tempfile::TempDir; + + // ── format_rfc3339 / civil_from_days ──────────────────────────────────── + + #[test] + fn format_rfc3339_epoch_zero() { + assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z"); + } + + #[test] + fn format_rfc3339_known_timestamps() { + assert_eq!(format_rfc3339(1_700_000_000), "2023-11-14T22:13:20Z"); + assert_eq!(format_rfc3339(1_754_812_800), "2025-08-10T08:00:00Z"); + assert_eq!(format_rfc3339(1_893_456_000), "2030-01-01T00:00:00Z"); + } + + #[test] + fn format_rfc3339_leap_day() { + assert_eq!(format_rfc3339(951_782_400), "2000-02-29T00:00:00Z"); + } + + // ── parse_duration ─────────────────────────────────────────────────────── + + #[test] + fn parse_duration_minutes() { + assert_eq!(parse_duration("30m").unwrap(), 30 * 60); + } + + #[test] + fn parse_duration_hours() { + assert_eq!(parse_duration("2h").unwrap(), 2 * 3600); + } + + #[test] + fn parse_duration_days() { + assert_eq!(parse_duration("1d").unwrap(), 86400); + } + + #[test] + fn parse_duration_seconds() { + assert_eq!(parse_duration("45s").unwrap(), 45); + } + + #[test] + fn parse_duration_combined_units() { + assert_eq!(parse_duration("1h30m").unwrap(), 3600 + 30 * 60); + } + + #[test] + fn parse_duration_rejects_empty() { + assert!(parse_duration("").is_err()); + } + + #[test] + fn parse_duration_rejects_missing_unit() { + assert!(parse_duration("30").is_err()); + } + + #[test] + fn parse_duration_rejects_unknown_unit() { + assert!(parse_duration("30x").is_err()); + } + + #[test] + fn parse_duration_rejects_unit_without_number() { + assert!(parse_duration("m").is_err()); + } + + #[test] + fn parse_duration_rejects_trailing_number() { + assert!(parse_duration("30m5").is_err()); + } + + // ── escape / unescape ──────────────────────────────────────────────────── + + #[test] + fn escape_unescape_round_trip() { + let s = "quotes \" and \\ and\nnewlines\tand\rtabs"; + assert_eq!(unescape(&escape(s)), s); + } + + #[test] + fn escape_produces_single_line_output() { + let s = "line one\nline two"; + assert!(!escape(s).contains('\n')); + } + + // ── LockFile to_json / parse ───────────────────────────────────────────── + + #[test] + fn lock_file_round_trips_through_json() { + let lf = LockFile { + locked_at: "2026-07-31T10:00:00Z".to_string(), + expires_at: Some("2026-07-31T10:30:00Z".to_string()), + reason: "Agent session active".to_string(), + operations: vec!["commit".to_string()], + }; + let json = lf.to_json(); + let parsed = LockFile::parse(&json).unwrap(); + assert_eq!(parsed, lf); + } + + #[test] + fn lock_file_round_trips_with_null_expiry() { + let lf = LockFile { + locked_at: "2026-07-31T10:00:00Z".to_string(), + expires_at: None, + reason: "no timeout".to_string(), + operations: vec!["commit".to_string()], + }; + let json = lf.to_json(); + assert!(json.contains("\"expires_at\":null")); + let parsed = LockFile::parse(&json).unwrap(); + assert_eq!(parsed, lf); + } + + #[test] + fn lock_file_json_is_single_line() { + let lf = LockFile { + locked_at: "2026-07-31T10:00:00Z".to_string(), + expires_at: None, + reason: "multi\nline\nreason".to_string(), + operations: vec!["commit".to_string()], + }; + let json = lf.to_json(); + assert_eq!(json.trim().lines().count(), 1); + } + + #[test] + fn lock_file_reason_containing_word_commit_does_not_confuse_operations() { + let lf = LockFile { + locked_at: "2026-07-31T10:00:00Z".to_string(), + expires_at: None, + reason: "no commits during agent session".to_string(), + operations: vec!["push".to_string()], + }; + let json = lf.to_json(); + let parsed = LockFile::parse(&json).unwrap(); + assert_eq!(parsed.operations, vec!["push".to_string()]); + assert!(!parsed.operations.iter().any(|o| o == "commit")); + } + + #[test] + fn lock_file_parse_rejects_malformed_content() { + assert!(LockFile::parse("not json at all").is_none()); + assert!(LockFile::parse("").is_none()); + assert!(LockFile::parse("{{{garbage").is_none()); + } + + #[test] + fn lock_file_parse_missing_locked_at_fails() { + assert!(LockFile::parse(r#"{"reason":"x","operations":["commit"]}"#).is_none()); + } + + #[test] + fn lock_file_parse_missing_operations_fails() { + assert!(LockFile::parse(r#"{"locked_at":"2026-01-01T00:00:00Z","reason":"x"}"#).is_none()); + } + + #[test] + fn lock_file_parse_empty_operations_array() { + let parsed = + LockFile::parse(r#"{"locked_at":"2026-01-01T00:00:00Z","reason":"x","operations":[]}"#) + .unwrap(); + assert!(parsed.operations.is_empty()); + } + + // ── lock hook script sanity ────────────────────────────────────────────── + + #[test] + fn lock_hook_script_is_valid_shell_shebang() { + assert!(LOCK_HOOK_SCRIPT.starts_with("#!/bin/sh")); + } + + #[test] + fn lock_hook_script_references_lock_file_and_backup() { + assert!(LOCK_HOOK_SCRIPT.contains("gitkit.lock")); + assert!(LOCK_HOOK_SCRIPT.contains("pre-commit.gitkit-orig")); + assert!(LOCK_HOOK_SCRIPT.contains("--no-verify")); + } + + // ── install_hook / uninstall_hook ──────────────────────────────────────── + + #[serial] + #[test] + fn install_hook_writes_executable_pre_commit_hook() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + let result = install_hook(); + assert!(result.is_ok()); + let hook_path = dir.path().join(".git").join("hooks").join("pre-commit"); + assert!(hook_path.exists()); + let content = std::fs::read_to_string(&hook_path).unwrap(); + assert_eq!(content, LOCK_HOOK_SCRIPT); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn install_hook_backs_up_existing_user_hook() { + let dir = TempDir::new().unwrap(); + let hooks_dir = dir.path().join(".git").join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho user hook\n").unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + let result = install_hook(); + assert!(result.is_ok()); + let backup = hooks_dir.join("pre-commit.gitkit-orig"); + assert!(backup.exists()); + assert!(std::fs::read_to_string(&backup) + .unwrap() + .contains("echo user hook")); + let content = std::fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); + assert_eq!(content, LOCK_HOOK_SCRIPT); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn install_hook_twice_does_not_overwrite_backup_with_own_script() { + let dir = TempDir::new().unwrap(); + let hooks_dir = dir.path().join(".git").join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho user hook\n").unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + install_hook().unwrap(); + install_hook().unwrap(); + + let backup = std::fs::read_to_string(hooks_dir.join("pre-commit.gitkit-orig")).unwrap(); + assert!(backup.contains("echo user hook")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn uninstall_hook_restores_backed_up_user_hook() { + let dir = TempDir::new().unwrap(); + let hooks_dir = dir.path().join(".git").join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho user hook\n").unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + install_hook().unwrap(); + uninstall_hook().unwrap(); + + let content = std::fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); + assert!(content.contains("echo user hook")); + assert!(!hooks_dir.join("pre-commit.gitkit-orig").exists()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn uninstall_hook_removes_hook_when_no_backup_existed() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + install_hook().unwrap(); + uninstall_hook().unwrap(); + + assert!(!dir + .path() + .join(".git") + .join("hooks") + .join("pre-commit") + .exists()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn uninstall_hook_leaves_non_gitkit_hook_untouched() { + let dir = TempDir::new().unwrap(); + let hooks_dir = dir.path().join(".git").join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + std::fs::write( + hooks_dir.join("pre-commit"), + "#!/bin/sh\necho someone else\n", + ) + .unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + uninstall_hook().unwrap(); + + let content = std::fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); + assert!(content.contains("echo someone else")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn uninstall_hook_noop_when_nothing_installed() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + assert!(uninstall_hook().is_ok()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + // ── lock() / unlock() / status() integration ──────────────────────────── + + #[serial] + #[test] + fn lock_then_status_reports_active_lock() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, Some("testing")).unwrap(); + let lock_path = dir.path().join(".git").join(LOCK_FILE_NAME); + assert!(lock_path.exists()); + let content = std::fs::read_to_string(&lock_path).unwrap(); + assert!(content.contains("\"reason\":\"testing\"")); + assert!(content.contains("\"operations\":[\"commit\"]")); + assert!(status().is_ok()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn lock_with_timeout_sets_expires_at() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(Some("30m"), None).unwrap(); + let content = + std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(content.contains("\"expires_at\":\"")); + assert!(!content.contains("\"expires_at\":null")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn lock_rejects_invalid_timeout() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + let result = lock(Some("nonsense"), None); + assert!(result.is_err()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn lock_twice_is_idempotent_and_updates_reason() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, Some("first")).unwrap(); + lock(None, Some("second")).unwrap(); + + let content = + std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(content.contains("\"reason\":\"second\"")); + assert!(!content.contains("\"reason\":\"first\"")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn unlock_removes_lock_file_and_restores_hook() { + let dir = TempDir::new().unwrap(); + let hooks_dir = dir.path().join(".git").join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + std::fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho user hook\n").unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, None).unwrap(); + assert!(dir.path().join(".git").join(LOCK_FILE_NAME).exists()); + + unlock().unwrap(); + assert!(!dir.path().join(".git").join(LOCK_FILE_NAME).exists()); + let content = std::fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); + assert!(content.contains("echo user hook")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn status_reports_no_lock_when_file_missing() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + assert!(status().is_ok()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn status_reports_malformed_lock_file_as_unlocked() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + std::fs::write(dir.path().join(".git").join(LOCK_FILE_NAME), "not json").unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + assert!(status().is_ok()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn status_reports_expired_lock() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let lf = LockFile { + locked_at: "2000-01-01T00:00:00Z".to_string(), + expires_at: Some("2000-01-01T00:01:00Z".to_string()), + reason: "long expired".to_string(), + operations: vec!["commit".to_string()], + }; + std::fs::write(dir.path().join(".git").join(LOCK_FILE_NAME), lf.to_json()).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + assert!(status().is_ok()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn run_dispatches_status_action() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + let result = run(LockArgs { + action: Some(LockAction::Status), + timeout: None, + reason: None, + }); + assert!(result.is_ok()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn run_dispatches_lock_action() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + let result = run(LockArgs { + action: None, + timeout: Some("1h".to_string()), + reason: Some("ci".to_string()), + }); + assert!(result.is_ok()); + assert!(dir.path().join(".git").join(LOCK_FILE_NAME).exists()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } +} diff --git a/src/main.rs b/src/main.rs index c6d30fc..ef11d50 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod git; mod hooks; mod ignore; mod init; +mod lock; mod status; mod utils; @@ -56,6 +57,10 @@ enum Command { #[command(subcommand)] action: builds::BuildCommand, }, + /// Block commits for the duration of an agent session + Lock(lock::LockArgs), + /// Remove an active commit lock + Unlock, } fn main() -> Result<()> { @@ -69,5 +74,7 @@ fn main() -> Result<()> { Some(Command::Attributes { action }) => attributes::run(action), Some(Command::Config { action }) => config::run(action), Some(Command::Build { action }) => builds::run(action), + Some(Command::Lock(args)) => lock::run(args), + Some(Command::Unlock) => lock::unlock(), } } diff --git a/tests/integration.rs b/tests/integration.rs index 563e456..276c94b 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -405,3 +405,201 @@ fn cli_config_apply_dry_run() { assert!(success); assert!(output.contains("[dry-run]") || output.contains("already set")); } + +// ═══════════════════════════════════════════════════════════════════════════ +// Lock / commit-blocking integration tests (real git repo, real git commit) +// ═══════════════════════════════════════════════════════════════════════════ + +fn init_git_repo(dir: &std::path::Path) { + let run = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .expect("Failed to run git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["init", "-q"]); + run(&["config", "user.email", "test@example.com"]); + run(&["config", "user.name", "Test User"]); + std::fs::write(dir.join("README.md"), "hello\n").unwrap(); + run(&["add", "README.md"]); +} + +fn git_commit_allow_empty(dir: &std::path::Path, msg: &str) -> (bool, String) { + let output = Command::new("git") + .args(["commit", "--allow-empty", "-m", msg]) + .current_dir(dir) + .output() + .expect("Failed to run git commit"); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + (output.status.success(), format!("{stdout}{stderr}")) +} + +#[test] +fn lock_fixture_commit_succeeds_unlocked_fails_locked_succeeds_after_unlock() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + // Unlocked: commit succeeds. + let (ok, _) = git_commit_allow_empty(dir.path(), "initial commit"); + assert!(ok, "commit should succeed with no lock active"); + + // Lock: commit fails and names the reason + how to unlock. + let lock_out = Command::new(&binary) + .args(["lock", "--reason", "Agent session active"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(lock_out.status.success()); + + let (ok, msg) = git_commit_allow_empty(dir.path(), "blocked commit"); + assert!(!ok, "commit should fail while locked"); + assert!(msg.contains("Agent session active"), "message was: {msg}"); + assert!(msg.contains("gitkit unlock"), "message was: {msg}"); + + // Unlock: commit succeeds again. + let unlock_out = Command::new(&binary) + .args(["unlock"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit unlock"); + assert!(unlock_out.status.success()); + + let (ok, _) = git_commit_allow_empty(dir.path(), "commit after unlock"); + assert!(ok, "commit should succeed after unlock"); +} + +#[test] +fn lock_fixture_expired_lock_does_not_block_commit() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock", "--timeout", "30m"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(lock_out.status.success()); + + // Confirm it blocks before expiry. + let (ok, _) = git_commit_allow_empty(dir.path(), "blocked commit"); + assert!(!ok, "commit should fail while lock has not expired"); + + // Rewrite the lock file with an expiry far in the past. + let lock_path = dir.path().join(".git").join("gitkit.lock"); + std::fs::write( + &lock_path, + r#"{"locked_at":"2000-01-01T00:00:00Z","expires_at":"2000-01-01T00:01:00Z","reason":"stale","operations":["commit"]}"#, + ) + .unwrap(); + + let (ok, _) = git_commit_allow_empty(dir.path(), "commit after expiry"); + assert!(ok, "commit should succeed once the lock has expired"); + + // status should report the lock as expired. + let status_out = Command::new(&binary) + .args(["lock", "status"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock status"); + let status_msg = String::from_utf8_lossy(&status_out.stdout); + assert!(status_msg.contains("expired"), "status was: {status_msg}"); +} + +#[test] +fn lock_fixture_malformed_lock_file_fails_open() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + // Install the hook via a real lock, then corrupt the lock file. + let lock_out = Command::new(&binary) + .args(["lock"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(lock_out.status.success()); + + let lock_path = dir.path().join(".git").join("gitkit.lock"); + std::fs::write(&lock_path, "not json at all {{{").unwrap(); + + let (ok, _) = git_commit_allow_empty(dir.path(), "commit with malformed lock"); + assert!(ok, "a malformed lock file must never block a commit"); +} + +#[test] +fn lock_fixture_locking_twice_is_idempotent() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + let run_lock = |reason: &str| { + let out = Command::new(&binary) + .args(["lock", "--reason", reason]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(out.status.success()); + }; + run_lock("first reason"); + run_lock("second reason"); + + let (ok, msg) = git_commit_allow_empty(dir.path(), "blocked commit"); + assert!(!ok); + assert!(msg.contains("second reason"), "message was: {msg}"); + assert!(!msg.contains("first reason"), "message was: {msg}"); + + let hooks_dir = dir.path().join(".git").join("hooks"); + assert!( + !hooks_dir.join("pre-commit.gitkit-orig").exists(), + "locking twice with no prior user hook must not fabricate a backup" + ); +} + +#[test] +fn lock_fixture_preserves_existing_user_pre_commit_hook() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let hooks_dir = dir.path().join(".git").join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + let user_hook = "#!/bin/sh\necho user-hook-ran >&2\nexit 0\n"; + std::fs::write(hooks_dir.join("pre-commit"), user_hook).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(hooks_dir.join("pre-commit")) + .unwrap() + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(hooks_dir.join("pre-commit"), perms).unwrap(); + } + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(lock_out.status.success()); + assert!(hooks_dir.join("pre-commit.gitkit-orig").exists()); + + let unlock_out = Command::new(&binary) + .args(["unlock"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit unlock"); + assert!(unlock_out.status.success()); + + let restored = std::fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); + assert_eq!(restored, user_hook); + assert!(!hooks_dir.join("pre-commit.gitkit-orig").exists()); + + // The restored user hook still runs on commit. + let (ok, msg) = git_commit_allow_empty(dir.path(), "commit runs user hook"); + assert!(ok); + assert!(msg.contains("user-hook-ran"), "message was: {msg}"); +} From 6a48cc2f52738a855fb256c908ebe4ed7a1aca5a Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 10 Aug 2026 19:19:51 -0500 Subject: [PATCH 02/20] feat(lock): add push-blocking lock with pre-push hook --- src/lock/mod.rs | 568 +++++++++++++++++++++++++++++++++++++++---- src/main.rs | 4 +- tests/integration.rs | 257 ++++++++++++++++++++ 3 files changed, 777 insertions(+), 52 deletions(-) diff --git a/src/lock/mod.rs b/src/lock/mod.rs index a674ee3..9f73a0c 100644 --- a/src/lock/mod.rs +++ b/src/lock/mod.rs @@ -8,7 +8,6 @@ use crate::hooks; use crate::utils::find_repo_root; const LOCK_FILE_NAME: &str = "gitkit.lock"; -const HOOK_NAME: &str = "pre-commit"; const BACKUP_SUFFIX: &str = "gitkit-orig"; const DEFAULT_REASON: &str = "Agent session active"; @@ -51,6 +50,69 @@ fi exit 0 "#; +/// The pre-push hook gitkit installs. Same shape as `LOCK_HOOK_SCRIPT`, but +/// checks for `"push"` in `operations` instead of `"commit"`. Git feeds ref +/// update lines on stdin; this hook never inspects them (decides purely from +/// the lock file) but still drains stdin itself on every exit path it owns, +/// so git never sees a broken pipe. When chaining to a backed-up user hook +/// via `exec`, stdin is left untouched so that hook can read the ref updates +/// itself. +const PUSH_HOOK_SCRIPT: &str = r#"#!/bin/sh +# Installed by `gitkit lock --push`. Blocks pushes while a push lock is active. +# See `gitkit lock status` / `gitkit unlock`. Bypass with `git push --no-verify`. + +git_dir=$(git rev-parse --git-dir 2>/dev/null) || { cat >/dev/null; exit 0; } +lock_file="$git_dir/gitkit.lock" +orig_hook="$git_dir/hooks/pre-push.gitkit-orig" + +blocked=0 +if [ -f "$lock_file" ]; then + ops=$(sed -n 's/.*"operations":\[\([^]]*\)\].*/\1/p' "$lock_file" 2>/dev/null) + if printf '%s' "$ops" | grep -qF '"push"'; then + expires=$(sed -n 's/.*"expires_at":"\([^"]*\)".*/\1/p' "$lock_file" 2>/dev/null) + blocked=1 + if [ -n "$expires" ]; then + now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + if [ "$now" \> "$expires" ]; then + blocked=0 + fi + fi + fi +fi + +if [ "$blocked" -eq 1 ]; then + cat >/dev/null + reason=$(sed -n 's/.*"reason":"\([^"]*\)".*/\1/p' "$lock_file" 2>/dev/null) + echo "gitkit: push blocked - ${reason:-Agent session active}" >&2 + echo "gitkit: run 'gitkit unlock' to remove the lock, or 'git push --no-verify' to bypass it" >&2 + exit 1 +fi + +if [ -x "$orig_hook" ]; then + exec "$orig_hook" "$@" +fi + +cat >/dev/null +exit 0 +"#; + +/// Describes one of the hooks gitkit installs, so the install/backup/restore +/// logic below is written once and shared between the commit and push locks. +struct HookSpec { + name: &'static str, + script: &'static str, +} + +const COMMIT_HOOK: HookSpec = HookSpec { + name: "pre-commit", + script: LOCK_HOOK_SCRIPT, +}; + +const PUSH_HOOK: HookSpec = HookSpec { + name: "pre-push", + script: PUSH_HOOK_SCRIPT, +}; + #[derive(Args)] pub struct LockArgs { #[command(subcommand)] @@ -58,9 +120,15 @@ pub struct LockArgs { /// Auto-expire the lock after a duration, e.g. 30m, 2h, 1d #[arg(long)] timeout: Option, - /// Message shown when a blocked commit is attempted + /// Message shown when a blocked commit or push is attempted #[arg(long)] reason: Option, + /// Block pushes (in addition to commits, if already locked) + #[arg(long)] + push: bool, + /// Block both commits and pushes + #[arg(long)] + all: bool, } #[derive(Subcommand)] @@ -72,7 +140,20 @@ enum LockAction { pub fn run(args: LockArgs) -> Result<()> { match args.action { Some(LockAction::Status) => status(), - None => lock(args.timeout.as_deref(), args.reason.as_deref()), + None => { + let ops = target_operations(args.push, args.all); + lock(args.timeout.as_deref(), args.reason.as_deref(), &ops) + } + } +} + +fn target_operations(push: bool, all: bool) -> Vec<&'static str> { + if all { + vec!["commit", "push"] + } else if push { + vec!["push"] + } else { + vec!["commit"] } } @@ -82,30 +163,67 @@ pub fn unlock() -> Result<()> { if path.exists() { fs::remove_file(&path).context("Failed to remove lock file")?; } - uninstall_hook()?; - println!("Unlocked. Commits are no longer blocked by gitkit."); + uninstall_hook(&COMMIT_HOOK)?; + uninstall_hook(&PUSH_HOOK)?; + println!("Unlocked. Commits and pushes are no longer blocked by gitkit."); Ok(()) } -fn lock(timeout: Option<&str>, reason: Option<&str>) -> Result<()> { +/// Adds `ops` to whatever lock is already on disk, creating one if none +/// exists. `locked_at` and `reason` carry over from an existing lock unless +/// a new `--reason` is given; `expires_at` follows `timeout` exactly as a +/// fresh lock would (omitting `--timeout` clears any prior expiry). +fn lock(timeout: Option<&str>, reason: Option<&str>, ops: &[&str]) -> Result<()> { let root = find_repo_root()?; + let path = lock_file_path(&root); + + let existing = fs::read_to_string(&path) + .ok() + .and_then(|content| LockFile::parse(&content)); + + let mut operations: Vec = existing + .as_ref() + .map(|lf| lf.operations.clone()) + .unwrap_or_default(); + for op in ops { + if !operations.iter().any(|o| o == op) { + operations.push((*op).to_string()); + } + } let expires_at = timeout .map(parse_duration) .transpose()? .map(|secs| format_rfc3339(unix_now() + secs)); + let locked_at = existing + .as_ref() + .map(|lf| lf.locked_at.clone()) + .unwrap_or_else(|| format_rfc3339(unix_now())); + + let reason = reason + .map(str::to_string) + .or_else(|| existing.as_ref().map(|lf| lf.reason.clone())) + .unwrap_or_else(|| DEFAULT_REASON.to_string()); + let lf = LockFile { - locked_at: format_rfc3339(unix_now()), + locked_at, expires_at, - reason: reason.unwrap_or(DEFAULT_REASON).to_string(), - operations: vec!["commit".to_string()], + reason, + operations, }; - fs::write(lock_file_path(&root), lf.to_json()).context("Failed to write lock file")?; - install_hook().context("Failed to install pre-commit hook")?; + fs::write(&path, lf.to_json()).context("Failed to write lock file")?; + + if lf.operations.iter().any(|op| op == "commit") { + install_hook(&COMMIT_HOOK).context("Failed to install pre-commit hook")?; + } + if lf.operations.iter().any(|op| op == "push") { + install_hook(&PUSH_HOOK).context("Failed to install pre-push hook")?; + } println!("Locked: {}", lf.reason); + println!("Locked operations: {}", lf.operations.join(", ")); match &lf.expires_at { Some(exp) => println!("Expires at: {exp}"), None => println!("Expires at: never (until `gitkit unlock`)"), @@ -124,72 +242,102 @@ fn status() -> Result<()> { let content = fs::read_to_string(&path).context("Failed to read lock file")?; let Some(lf) = LockFile::parse(&content) else { - println!("Lock file is malformed - treated as unlocked (commits are not blocked)."); + println!("Lock file is malformed - treated as unlocked (nothing is blocked)."); return Ok(()); }; - if !lf.operations.iter().any(|op| op == "commit") { - println!("No commit lock active."); + if lf.operations.is_empty() { + println!("No lock active."); return Ok(()); } - let now = format_rfc3339(unix_now()); - println!("Locked: {}", lf.reason); - println!("Locked at: {}", lf.locked_at); + for line in status_report(&lf, &format_rfc3339(unix_now())) { + println!("{line}"); + } + Ok(()) +} + +/// Builds the `lock status` report as plain lines, kept separate from +/// `status()` so the per-operation reporting can be exercised directly in +/// tests without spawning a subprocess to capture stdout. +fn status_report(lf: &LockFile, now: &str) -> Vec { + let expired = matches!(&lf.expires_at, Some(exp) if now > exp.as_str()); + + let mut lines = vec![ + format!("Locked: {}", lf.reason), + format!("Locked at: {}", lf.locked_at), + ]; match &lf.expires_at { - Some(exp) if now.as_str() > exp.as_str() => { - println!("Expires at: {exp} (expired - commits are not blocked)"); + Some(exp) if expired => { + lines.push(format!("Expires at: {exp} (expired - nothing is blocked)")); } - Some(exp) => println!("Expires at: {exp}"), - None => println!("Expires at: never"), + Some(exp) => lines.push(format!("Expires at: {exp}")), + None => lines.push("Expires at: never".to_string()), } - Ok(()) + + let commit_locked = !expired && lf.operations.iter().any(|op| op == "commit"); + let push_locked = !expired && lf.operations.iter().any(|op| op == "push"); + lines.push(format!( + "Commit: {}", + if commit_locked { + "locked" + } else { + "not locked" + } + )); + lines.push(format!( + "Push: {}", + if push_locked { "locked" } else { "not locked" } + )); + lines } fn lock_file_path(root: &Path) -> PathBuf { root.join(".git").join(LOCK_FILE_NAME) } -fn install_hook() -> Result<()> { +fn install_hook(spec: &HookSpec) -> Result<()> { let dir = hooks::hooks_dir()?; fs::create_dir_all(&dir).context("Failed to create hooks directory")?; - let hook_path = dir.join(HOOK_NAME); - let backup_path = dir.join(format!("{HOOK_NAME}.{BACKUP_SUFFIX}")); + let hook_path = dir.join(spec.name); + let backup_path = dir.join(format!("{}.{BACKUP_SUFFIX}", spec.name)); if hook_path.exists() { let existing = fs::read_to_string(&hook_path).unwrap_or_default(); - if existing.trim() != LOCK_HOOK_SCRIPT.trim() { + if existing.trim() != spec.script.trim() { fs::copy(&hook_path, &backup_path) - .context("Failed to back up existing pre-commit hook")?; + .with_context(|| format!("Failed to back up existing {} hook", spec.name))?; } } - fs::write(&hook_path, LOCK_HOOK_SCRIPT).context("Failed to write pre-commit hook")?; + fs::write(&hook_path, spec.script) + .with_context(|| format!("Failed to write {} hook", spec.name))?; hooks::set_executable(&hook_path)?; Ok(()) } /// Only touches the hook file if it's still the one gitkit installed — /// never clobbers a hook that was manually replaced after locking. -fn uninstall_hook() -> Result<()> { +fn uninstall_hook(spec: &HookSpec) -> Result<()> { let dir = hooks::hooks_dir()?; - let hook_path = dir.join(HOOK_NAME); - let backup_path = dir.join(format!("{HOOK_NAME}.{BACKUP_SUFFIX}")); + let hook_path = dir.join(spec.name); + let backup_path = dir.join(format!("{}.{BACKUP_SUFFIX}", spec.name)); if !hook_path.exists() { return Ok(()); } let existing = fs::read_to_string(&hook_path).unwrap_or_default(); - if existing.trim() != LOCK_HOOK_SCRIPT.trim() { + if existing.trim() != spec.script.trim() { return Ok(()); } if backup_path.exists() { fs::rename(&backup_path, &hook_path) - .context("Failed to restore original pre-commit hook")?; + .with_context(|| format!("Failed to restore original {} hook", spec.name))?; hooks::set_executable(&hook_path)?; } else { - fs::remove_file(&hook_path).context("Failed to remove pre-commit hook")?; + fs::remove_file(&hook_path) + .with_context(|| format!("Failed to remove {} hook", spec.name))?; } Ok(()) } @@ -582,6 +730,27 @@ mod tests { assert!(LOCK_HOOK_SCRIPT.contains("--no-verify")); } + #[test] + fn push_hook_script_is_valid_shell_shebang() { + assert!(PUSH_HOOK_SCRIPT.starts_with("#!/bin/sh")); + } + + #[test] + fn push_hook_script_references_lock_file_and_backup() { + assert!(PUSH_HOOK_SCRIPT.contains("gitkit.lock")); + assert!(PUSH_HOOK_SCRIPT.contains("pre-push.gitkit-orig")); + assert!(PUSH_HOOK_SCRIPT.contains("--no-verify")); + assert!(PUSH_HOOK_SCRIPT.contains("\"push\"")); + } + + #[test] + fn push_hook_script_drains_stdin_on_every_self_owned_exit() { + // The chained-exec path deliberately leaves stdin untouched for the + // downstream hook; every path where this hook decides the exit code + // itself must drain stdin so git never sees a broken pipe. + assert!(PUSH_HOOK_SCRIPT.contains("cat >/dev/null")); + } + // ── install_hook / uninstall_hook ──────────────────────────────────────── #[serial] @@ -592,7 +761,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - let result = install_hook(); + let result = install_hook(&COMMIT_HOOK); assert!(result.is_ok()); let hook_path = dir.path().join(".git").join("hooks").join("pre-commit"); assert!(hook_path.exists()); @@ -604,6 +773,26 @@ mod tests { } } + #[serial] + #[test] + fn install_hook_writes_executable_pre_push_hook() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + let result = install_hook(&PUSH_HOOK); + assert!(result.is_ok()); + let hook_path = dir.path().join(".git").join("hooks").join("pre-push"); + assert!(hook_path.exists()); + let content = std::fs::read_to_string(&hook_path).unwrap(); + assert_eq!(content, PUSH_HOOK_SCRIPT); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + #[serial] #[test] fn install_hook_backs_up_existing_user_hook() { @@ -614,7 +803,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - let result = install_hook(); + let result = install_hook(&COMMIT_HOOK); assert!(result.is_ok()); let backup = hooks_dir.join("pre-commit.gitkit-orig"); assert!(backup.exists()); @@ -639,8 +828,8 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - install_hook().unwrap(); - install_hook().unwrap(); + install_hook(&COMMIT_HOOK).unwrap(); + install_hook(&COMMIT_HOOK).unwrap(); let backup = std::fs::read_to_string(hooks_dir.join("pre-commit.gitkit-orig")).unwrap(); assert!(backup.contains("echo user hook")); @@ -660,8 +849,8 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - install_hook().unwrap(); - uninstall_hook().unwrap(); + install_hook(&COMMIT_HOOK).unwrap(); + uninstall_hook(&COMMIT_HOOK).unwrap(); let content = std::fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); assert!(content.contains("echo user hook")); @@ -680,8 +869,8 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - install_hook().unwrap(); - uninstall_hook().unwrap(); + install_hook(&COMMIT_HOOK).unwrap(); + uninstall_hook(&COMMIT_HOOK).unwrap(); assert!(!dir .path() @@ -709,7 +898,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - uninstall_hook().unwrap(); + uninstall_hook(&COMMIT_HOOK).unwrap(); let content = std::fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); assert!(content.contains("echo someone else")); @@ -727,7 +916,8 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - assert!(uninstall_hook().is_ok()); + assert!(uninstall_hook(&COMMIT_HOOK).is_ok()); + assert!(uninstall_hook(&PUSH_HOOK).is_ok()); if let Some(orig) = original { let _ = std::env::set_current_dir(orig); @@ -744,7 +934,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - lock(None, Some("testing")).unwrap(); + lock(None, Some("testing"), &["commit"]).unwrap(); let lock_path = dir.path().join(".git").join(LOCK_FILE_NAME); assert!(lock_path.exists()); let content = std::fs::read_to_string(&lock_path).unwrap(); @@ -765,7 +955,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - lock(Some("30m"), None).unwrap(); + lock(Some("30m"), None, &["commit"]).unwrap(); let content = std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); assert!(content.contains("\"expires_at\":\"")); @@ -784,7 +974,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - let result = lock(Some("nonsense"), None); + let result = lock(Some("nonsense"), None, &["commit"]); assert!(result.is_err()); if let Some(orig) = original { @@ -800,8 +990,8 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - lock(None, Some("first")).unwrap(); - lock(None, Some("second")).unwrap(); + lock(None, Some("first"), &["commit"]).unwrap(); + lock(None, Some("second"), &["commit"]).unwrap(); let content = std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); @@ -813,6 +1003,117 @@ mod tests { } } + #[serial] + #[test] + fn lock_push_adds_push_operation_and_installs_pre_push_hook() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, Some("agent session"), &["push"]).unwrap(); + + let content = + std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(content.contains("\"operations\":[\"push\"]")); + assert!(dir + .path() + .join(".git") + .join("hooks") + .join("pre-push") + .exists()); + assert!(!dir + .path() + .join(".git") + .join("hooks") + .join("pre-commit") + .exists()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn lock_all_locks_commit_and_push_together() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, None, &["commit", "push"]).unwrap(); + + let content = + std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(content.contains("\"operations\":[\"commit\",\"push\"]")); + assert!(dir + .path() + .join(".git") + .join("hooks") + .join("pre-commit") + .exists()); + assert!(dir + .path() + .join(".git") + .join("hooks") + .join("pre-push") + .exists()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn lock_push_added_to_existing_commit_lock_preserves_locked_at_and_reason() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, Some("original reason"), &["commit"]).unwrap(); + let lock_path = dir.path().join(".git").join(LOCK_FILE_NAME); + let first = LockFile::parse(&std::fs::read_to_string(&lock_path).unwrap()).unwrap(); + + // Add the push lock without a new --reason. + lock(None, None, &["push"]).unwrap(); + let second = LockFile::parse(&std::fs::read_to_string(&lock_path).unwrap()).unwrap(); + + assert_eq!(second.locked_at, first.locked_at); + assert_eq!(second.reason, "original reason"); + assert_eq!( + second.operations, + vec!["commit".to_string(), "push".to_string()] + ); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn lock_push_added_with_new_reason_overrides_it() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, Some("original reason"), &["commit"]).unwrap(); + lock(None, Some("new reason"), &["push"]).unwrap(); + + let content = + std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(content.contains("\"reason\":\"new reason\"")); + assert!(!content.contains("original reason")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + #[serial] #[test] fn unlock_removes_lock_file_and_restores_hook() { @@ -823,7 +1124,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - lock(None, None).unwrap(); + lock(None, None, &["commit"]).unwrap(); assert!(dir.path().join(".git").join(LOCK_FILE_NAME).exists()); unlock().unwrap(); @@ -836,6 +1137,41 @@ mod tests { } } + #[serial] + #[test] + fn unlock_restores_both_backed_up_hooks() { + let dir = TempDir::new().unwrap(); + let hooks_dir = dir.path().join(".git").join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + std::fs::write( + hooks_dir.join("pre-commit"), + "#!/bin/sh\necho commit user\n", + ) + .unwrap(); + std::fs::write(hooks_dir.join("pre-push"), "#!/bin/sh\necho push user\n").unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, None, &["commit", "push"]).unwrap(); + assert!(hooks_dir.join("pre-commit.gitkit-orig").exists()); + assert!(hooks_dir.join("pre-push.gitkit-orig").exists()); + + unlock().unwrap(); + + assert!(std::fs::read_to_string(hooks_dir.join("pre-commit")) + .unwrap() + .contains("echo commit user")); + assert!(std::fs::read_to_string(hooks_dir.join("pre-push")) + .unwrap() + .contains("echo push user")); + assert!(!hooks_dir.join("pre-commit.gitkit-orig").exists()); + assert!(!hooks_dir.join("pre-push.gitkit-orig").exists()); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + #[serial] #[test] fn status_reports_no_lock_when_file_missing() { @@ -901,6 +1237,8 @@ mod tests { action: Some(LockAction::Status), timeout: None, reason: None, + push: false, + all: false, }); assert!(result.is_ok()); @@ -921,6 +1259,8 @@ mod tests { action: None, timeout: Some("1h".to_string()), reason: Some("ci".to_string()), + push: false, + all: false, }); assert!(result.is_ok()); assert!(dir.path().join(".git").join(LOCK_FILE_NAME).exists()); @@ -929,4 +1269,132 @@ mod tests { let _ = std::env::set_current_dir(orig); } } + + #[serial] + #[test] + fn run_dispatches_lock_push_action() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + let result = run(LockArgs { + action: None, + timeout: None, + reason: None, + push: true, + all: false, + }); + assert!(result.is_ok()); + let content = + std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(content.contains("\"operations\":[\"push\"]")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + #[serial] + #[test] + fn run_dispatches_lock_all_action() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + let result = run(LockArgs { + action: None, + timeout: None, + reason: None, + push: false, + all: true, + }); + assert!(result.is_ok()); + let content = + std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(content.contains("\"operations\":[\"commit\",\"push\"]")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } + + // ── target_operations ──────────────────────────────────────────────────── + + #[test] + fn target_operations_defaults_to_commit() { + assert_eq!(target_operations(false, false), vec!["commit"]); + } + + #[test] + fn target_operations_push_flag_locks_push_only() { + assert_eq!(target_operations(true, false), vec!["push"]); + } + + #[test] + fn target_operations_all_flag_locks_both() { + assert_eq!(target_operations(false, true), vec!["commit", "push"]); + } + + // ── status_report() per-operation reporting ────────────────────────────── + + #[test] + fn status_report_shows_push_locked_commit_not_locked() { + let lf = LockFile { + locked_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: None, + reason: "agent session".to_string(), + operations: vec!["push".to_string()], + }; + let lines = status_report(&lf, "2026-01-01T00:05:00Z").join("\n"); + assert!(lines.contains("Commit: not locked"), "status was: {lines}"); + assert!(lines.contains("Push: locked"), "status was: {lines}"); + } + + #[test] + fn status_report_shows_both_locked_for_all() { + let lf = LockFile { + locked_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: None, + reason: "agent session".to_string(), + operations: vec!["commit".to_string(), "push".to_string()], + }; + let lines = status_report(&lf, "2026-01-01T00:05:00Z").join("\n"); + assert!(lines.contains("Commit: locked"), "status was: {lines}"); + assert!(lines.contains("Push: locked"), "status was: {lines}"); + } + + #[test] + fn status_report_expired_lock_shows_neither_operation_locked() { + let lf = LockFile { + locked_at: "2000-01-01T00:00:00Z".to_string(), + expires_at: Some("2000-01-01T00:01:00Z".to_string()), + reason: "long expired".to_string(), + operations: vec!["commit".to_string(), "push".to_string()], + }; + let lines = status_report(&lf, "2026-01-01T00:00:00Z").join("\n"); + assert!(lines.contains("expired"), "status was: {lines}"); + assert!(lines.contains("Commit: not locked"), "status was: {lines}"); + assert!(lines.contains("Push: not locked"), "status was: {lines}"); + } + + #[serial] + #[test] + fn status_reports_commit_and_push_independently_via_run() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let original = std::env::current_dir().ok(); + let _ = std::env::set_current_dir(dir.path()); + + lock(None, None, &["push"]).unwrap(); + assert!(status().is_ok()); + let content = + std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(content.contains("\"operations\":[\"push\"]")); + + if let Some(orig) = original { + let _ = std::env::set_current_dir(orig); + } + } } diff --git a/src/main.rs b/src/main.rs index ef11d50..ded2c57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,9 +57,9 @@ enum Command { #[command(subcommand)] action: builds::BuildCommand, }, - /// Block commits for the duration of an agent session + /// Block commits and/or pushes for the duration of an agent session Lock(lock::LockArgs), - /// Remove an active commit lock + /// Remove an active commit/push lock Unlock, } diff --git a/tests/integration.rs b/tests/integration.rs index 276c94b..06f0101 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -603,3 +603,260 @@ fn lock_fixture_preserves_existing_user_pre_commit_hook() { assert!(ok); assert!(msg.contains("user-hook-ran"), "message was: {msg}"); } + +// ═══════════════════════════════════════════════════════════════════════════ +// Lock / push-blocking integration tests (real git repo, real git push) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Sets up `dir` as a git repo with an initial commit and a local bare +/// remote named `origin`, so `git push` can be exercised without touching +/// the network. Returns the bare remote's `TempDir` - keep it alive for the +/// duration of the test. +fn init_git_repo_with_remote(dir: &std::path::Path) -> TempDir { + let remote_dir = TempDir::new().unwrap(); + let status = Command::new("git") + .args(["init", "--bare", "-q"]) + .current_dir(remote_dir.path()) + .status() + .expect("Failed to init bare remote"); + assert!(status.success()); + + init_git_repo(dir); + let (ok, _) = git_commit_allow_empty(dir, "initial commit"); + assert!(ok, "initial commit should succeed"); + + let status = Command::new("git") + .args([ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ]) + .current_dir(dir) + .status() + .expect("Failed to add remote"); + assert!(status.success()); + + remote_dir +} + +fn git_push_head(dir: &std::path::Path) -> (bool, String) { + let output = Command::new("git") + .args(["push", "origin", "HEAD:refs/heads/main"]) + .current_dir(dir) + .output() + .expect("Failed to run git push"); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + (output.status.success(), format!("{stdout}{stderr}")) +} + +#[test] +fn push_fixture_push_succeeds_unlocked_fails_locked_succeeds_after_unlock() { + let dir = TempDir::new().unwrap(); + let _remote = init_git_repo_with_remote(dir.path()); + let binary = gitkit_binary(); + + // Unlocked: push succeeds. + let (ok, msg) = git_push_head(dir.path()); + assert!(ok, "push should succeed with no lock active: {msg}"); + + // Lock --push: push fails and names the reason + how to unlock. + let lock_out = Command::new(&binary) + .args(["lock", "--push", "--reason", "Agent session active"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock --push"); + assert!(lock_out.status.success()); + + let (ok, _) = git_commit_allow_empty(dir.path(), "another commit"); + assert!(ok, "commit should still succeed - only push is locked"); + + let (ok, msg) = git_push_head(dir.path()); + assert!(!ok, "push should fail while push-locked"); + assert!(msg.contains("Agent session active"), "message was: {msg}"); + assert!(msg.contains("gitkit unlock"), "message was: {msg}"); + + // Unlock: push succeeds again. + let unlock_out = Command::new(&binary) + .args(["unlock"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit unlock"); + assert!(unlock_out.status.success()); + + let (ok, _) = git_push_head(dir.path()); + assert!(ok, "push should succeed after unlock"); +} + +#[test] +fn push_fixture_expired_lock_does_not_block_push() { + let dir = TempDir::new().unwrap(); + let _remote = init_git_repo_with_remote(dir.path()); + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock", "--push", "--timeout", "30m"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock --push"); + assert!(lock_out.status.success()); + + // Confirm it blocks before expiry. + let (ok, _) = git_push_head(dir.path()); + assert!(!ok, "push should fail while lock has not expired"); + + // Rewrite the lock file with an expiry far in the past. + let lock_path = dir.path().join(".git").join("gitkit.lock"); + std::fs::write( + &lock_path, + r#"{"locked_at":"2000-01-01T00:00:00Z","expires_at":"2000-01-01T00:01:00Z","reason":"stale","operations":["push"]}"#, + ) + .unwrap(); + + let (ok, _) = git_push_head(dir.path()); + assert!(ok, "push should succeed once the lock has expired"); + + let status_out = Command::new(&binary) + .args(["lock", "status"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock status"); + let status_msg = String::from_utf8_lossy(&status_out.stdout); + assert!(status_msg.contains("expired"), "status was: {status_msg}"); +} + +#[test] +fn push_fixture_malformed_lock_file_fails_open() { + let dir = TempDir::new().unwrap(); + let _remote = init_git_repo_with_remote(dir.path()); + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock", "--push"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock --push"); + assert!(lock_out.status.success()); + + let lock_path = dir.path().join(".git").join("gitkit.lock"); + std::fs::write(&lock_path, "not json at all {{{").unwrap(); + + let (ok, _) = git_push_head(dir.path()); + assert!(ok, "a malformed lock file must never block a push"); +} + +#[test] +fn push_fixture_commit_lock_alone_does_not_block_push() { + let dir = TempDir::new().unwrap(); + let _remote = init_git_repo_with_remote(dir.path()); + let binary = gitkit_binary(); + + // Only the commit lock is active - push must remain unblocked. + let lock_out = Command::new(&binary) + .args(["lock"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(lock_out.status.success()); + + let (ok, msg) = git_push_head(dir.path()); + assert!(ok, "push should succeed while only commit is locked: {msg}"); + + let (ok, _) = git_commit_allow_empty(dir.path(), "blocked commit"); + assert!(!ok, "commit should still fail while commit-locked"); +} + +#[test] +fn push_fixture_all_locks_both_commit_and_push() { + let dir = TempDir::new().unwrap(); + let _remote = init_git_repo_with_remote(dir.path()); + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock", "--all", "--reason", "full lockdown"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock --all"); + assert!(lock_out.status.success()); + + let (ok, _) = git_commit_allow_empty(dir.path(), "blocked commit"); + assert!(!ok, "commit should fail under --all"); + + let (ok, msg) = git_push_head(dir.path()); + assert!(!ok, "push should fail under --all: {msg}"); + assert!(msg.contains("full lockdown"), "message was: {msg}"); +} + +#[test] +fn push_fixture_adding_push_lock_preserves_commit_lock_reason() { + let dir = TempDir::new().unwrap(); + let _remote = init_git_repo_with_remote(dir.path()); + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock", "--reason", "original session"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(lock_out.status.success()); + + // Extend to push without a new --reason. + let lock_out = Command::new(&binary) + .args(["lock", "--push"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock --push"); + assert!(lock_out.status.success()); + + let (ok, msg) = git_push_head(dir.path()); + assert!(!ok, "push should fail once push is locked"); + assert!(msg.contains("original session"), "message was: {msg}"); + + let (ok, msg) = git_commit_allow_empty(dir.path(), "blocked commit"); + assert!(!ok, "commit should still fail too"); + assert!(msg.contains("original session"), "message was: {msg}"); +} + +#[test] +fn push_fixture_preserves_existing_user_pre_push_hook() { + let dir = TempDir::new().unwrap(); + let _remote = init_git_repo_with_remote(dir.path()); + let hooks_dir = dir.path().join(".git").join("hooks"); + let user_hook = "#!/bin/sh\ncat >/dev/null\necho user-push-hook-ran >&2\nexit 0\n"; + std::fs::write(hooks_dir.join("pre-push"), user_hook).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(hooks_dir.join("pre-push")) + .unwrap() + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(hooks_dir.join("pre-push"), perms).unwrap(); + } + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock", "--push"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock --push"); + assert!(lock_out.status.success()); + assert!(hooks_dir.join("pre-push.gitkit-orig").exists()); + + let unlock_out = Command::new(&binary) + .args(["unlock"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit unlock"); + assert!(unlock_out.status.success()); + + let restored = std::fs::read_to_string(hooks_dir.join("pre-push")).unwrap(); + assert_eq!(restored, user_hook); + assert!(!hooks_dir.join("pre-push.gitkit-orig").exists()); + + // The restored user hook still runs on push. + let (ok, msg) = git_push_head(dir.path()); + assert!(ok); + assert!(msg.contains("user-push-hook-ran"), "message was: {msg}"); +} From bf07b3cbb3949bb177186474496f074b8cbc431a Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 11 Aug 2026 09:25:17 -0500 Subject: [PATCH 03/20] feat(lock): add --json flag to lock status for machine-readable output --- docs/lock.md | 68 +++++++++ src/lock/mod.rs | 333 ++++++++++++++++++++++++++++++++++++++++--- tests/integration.rs | 151 ++++++++++++++++++++ 3 files changed, 529 insertions(+), 23 deletions(-) diff --git a/docs/lock.md b/docs/lock.md index c3d790b..08e2631 100644 --- a/docs/lock.md +++ b/docs/lock.md @@ -14,6 +14,7 @@ gitkit lock # block commits until `gitkit unlock` gitkit lock --reason "Agent session" # custom message shown on a blocked commit gitkit lock --timeout 30m # auto-expires after 30 minutes gitkit lock status # show whether a lock is active +gitkit lock status --json # machine-readable status, see below gitkit unlock # remove the lock ``` @@ -35,6 +36,73 @@ check passes. `gitkit unlock` restores it and removes the backup. The lock is per-repository, local only, and never committed or pushed — it lives entirely under `.git/`. +## Machine-readable status: `lock status --json` + +`gitkit lock status --json` emits the same state the human-readable +`gitkit lock status` shows, as a single line of JSON on stdout, so another +program can check whether a repository is locked instead of discovering it +by having a commit rejected. This is a **read-only** surface: gitkit does +not call out to, or know about, whatever consumes it. + +```bash +$ gitkit lock status --json +{"active":true,"operations":["commit"],"locked_at":"2026-01-01T00:00:00Z","expires_at":null,"reason":"Agent session","expired":false} +``` + +Fields, all always present (this key set is a supported contract — do not +rely on a key being renamed or removed without a version bump): + +| Key | Type | Meaning | +|---------------|-------------------|--------------------------------------------------------------------------| +| `active` | `bool` | Whether the lock currently blocks the operations it lists — `false` if there is no lock, the lock file is malformed, `operations` is empty, or the lock has expired. | +| `operations` | `string[]` | The operations the lock covers (e.g. `"commit"`, `"push"`). Empty when there is no lock. | +| `locked_at` | `string \| null` | RFC 3339 timestamp the lock was set, or `null` when there is no lock. | +| `expires_at` | `string \| null` | RFC 3339 timestamp the lock expires, or `null` for a lock with no timeout (or no lock at all). | +| `reason` | `string \| null` | The `--reason` text, or `null` when there is no lock. | +| `expired` | `bool` | Whether `expires_at` is in the past, resolved at read time. There is no background process — expiry is only ever checked when something reads the lock. | + +A missing or malformed lock file reports the same payload as no lock at +all (`active: false`, every other field `null`/empty) — a corrupt lock +file never blocks a caller, matching the human-readable behavior above. + +**Exit code** doubles as the machine-readable signal, so a shell caller can +branch without parsing JSON: `0` when no lock is in force (including an +expired or malformed one), non-zero when one is active. The JSON is still +written to stdout in both cases. + +`gitkit lock status --json` works from any directory inside the repository, +the same as the human-readable form. + +## File format: `.git/gitkit.lock` + +The lock state lives at `.git/gitkit.lock` as a single line of JSON. A +consumer may read this file directly instead of shelling out to +`gitkit lock status --json` — both read the same file, and the schema +below is the supported contract for either path. + +```json +{"locked_at":"2026-01-01T00:00:00Z","expires_at":"2026-01-01T00:30:00Z","reason":"Agent session","operations":["commit"]} +``` + +| Key | Type | Meaning | +|---------------|-------------------|-------------------------------------------------| +| `locked_at` | `string` | RFC 3339 timestamp the lock was set. | +| `expires_at` | `string \| null` | RFC 3339 timestamp the lock expires, or `null` for no timeout. | +| `reason` | `string` | The `--reason` text, or empty string if none was given. | +| `operations` | `string[]` | The operations the lock covers. | + +Notes for a direct reader: + +- A missing file means no lock is active. +- Expiry is not enforced by anything in the file itself — a reader must + compare `expires_at` against the current time itself, the same way + `gitkit lock status --json` resolves its `expired` field. +- Treat an unparseable file the same as a missing one: unlocked. gitkit's + own hooks and `status` do the same, so a corrupt file never blocks + anything on either side. +- This file is local only, lives entirely under `.git/`, and is never + committed or pushed. + ## Limitation: `--no-verify` `git commit --no-verify` bypasses all pre-commit hooks, including this diff --git a/src/lock/mod.rs b/src/lock/mod.rs index 9f73a0c..b7c172d 100644 --- a/src/lock/mod.rs +++ b/src/lock/mod.rs @@ -134,12 +134,17 @@ pub struct LockArgs { #[derive(Subcommand)] enum LockAction { /// Show whether a lock is currently active - Status, + Status { + /// Emit machine-readable JSON instead of the human-readable summary. + /// Exit code is 0 when no lock is in force, non-zero when one is. + #[arg(long)] + json: bool, + }, } pub fn run(args: LockArgs) -> Result<()> { match args.action { - Some(LockAction::Status) => status(), + Some(LockAction::Status { json }) => status(json), None => { let ops = target_operations(args.push, args.all); lock(args.timeout.as_deref(), args.reason.as_deref(), &ops) @@ -231,32 +236,93 @@ fn lock(timeout: Option<&str>, reason: Option<&str>, ops: &[&str]) -> Result<()> Ok(()) } -fn status() -> Result<()> { - let root = find_repo_root()?; - let path = lock_file_path(&root); +/// The three states `.git/gitkit.lock` can resolve to on read: absent, +/// present but unparseable, or present and valid. Kept distinct from +/// `LockFile` itself so both the human-readable and `--json` status paths +/// share one read, one interpretation of "no lock" vs "malformed lock", and +/// one place to extend if a fourth state is ever needed. +enum LockState { + None, + Malformed, + Present(LockFile), +} +fn read_lock_state(path: &Path) -> Result { if !path.exists() { - println!("No lock active."); - return Ok(()); + return Ok(LockState::None); } + let content = fs::read_to_string(path).context("Failed to read lock file")?; + match LockFile::parse(&content) { + Some(lf) => Ok(LockState::Present(lf)), + None => Ok(LockState::Malformed), + } +} - let content = fs::read_to_string(&path).context("Failed to read lock file")?; - let Some(lf) = LockFile::parse(&content) else { - println!("Lock file is malformed - treated as unlocked (nothing is blocked)."); - return Ok(()); - }; +fn status(json: bool) -> Result<()> { + let root = find_repo_root()?; + let path = lock_file_path(&root); + let state = read_lock_state(&path)?; + let now = format_rfc3339(unix_now()); - if lf.operations.is_empty() { - println!("No lock active."); - return Ok(()); + if json { + let (payload, active) = status_json(&state, &now); + println!("{payload}"); + std::process::exit(if active { 1 } else { 0 }); } - for line in status_report(&lf, &format_rfc3339(unix_now())) { - println!("{line}"); + match state { + LockState::None => println!("No lock active."), + LockState::Malformed => { + println!("Lock file is malformed - treated as unlocked (nothing is blocked).") + } + LockState::Present(lf) if lf.operations.is_empty() => println!("No lock active."), + LockState::Present(lf) => { + for line in status_report(&lf, &now) { + println!("{line}"); + } + } } Ok(()) } +/// Builds the `lock status --json` payload documented in `docs/lock.md`, and +/// whether the lock is currently in force (used for the exit code). Kept +/// separate from `status()` so the exact key set and values can be asserted +/// in tests without spawning a subprocess or triggering `process::exit`. +/// +/// Key set is part of the documented contract — do not rename, add, or +/// remove keys without updating `docs/lock.md` and the consumers of it. +fn status_json(state: &LockState, now: &str) -> (String, bool) { + match state { + LockState::None | LockState::Malformed => ( + "{\"active\":false,\"operations\":[],\"locked_at\":null,\"expires_at\":null,\ + \"reason\":null,\"expired\":false}" + .to_string(), + false, + ), + LockState::Present(lf) => { + let expired = matches!(&lf.expires_at, Some(exp) if now > exp.as_str()); + let active = !expired && !lf.operations.is_empty(); + let ops = lf + .operations + .iter() + .map(|o| format!("\"{}\"", escape(o))) + .collect::>() + .join(","); + let expires_at = match &lf.expires_at { + Some(e) => format!("\"{}\"", escape(e)), + None => "null".to_string(), + }; + let payload = format!( + "{{\"active\":{active},\"operations\":[{ops}],\"locked_at\":\"{}\",\"expires_at\":{expires_at},\"reason\":\"{}\",\"expired\":{expired}}}", + escape(&lf.locked_at), + escape(&lf.reason), + ); + (payload, active) + } + } +} + /// Builds the `lock status` report as plain lines, kept separate from /// `status()` so the per-operation reporting can be exercised directly in /// tests without spawning a subprocess to capture stdout. @@ -940,7 +1006,7 @@ mod tests { let content = std::fs::read_to_string(&lock_path).unwrap(); assert!(content.contains("\"reason\":\"testing\"")); assert!(content.contains("\"operations\":[\"commit\"]")); - assert!(status().is_ok()); + assert!(status(false).is_ok()); if let Some(orig) = original { let _ = std::env::set_current_dir(orig); @@ -1180,7 +1246,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - assert!(status().is_ok()); + assert!(status(false).is_ok()); if let Some(orig) = original { let _ = std::env::set_current_dir(orig); @@ -1196,7 +1262,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - assert!(status().is_ok()); + assert!(status(false).is_ok()); if let Some(orig) = original { let _ = std::env::set_current_dir(orig); @@ -1218,7 +1284,7 @@ mod tests { let original = std::env::current_dir().ok(); let _ = std::env::set_current_dir(dir.path()); - assert!(status().is_ok()); + assert!(status(false).is_ok()); if let Some(orig) = original { let _ = std::env::set_current_dir(orig); @@ -1234,7 +1300,7 @@ mod tests { let _ = std::env::set_current_dir(dir.path()); let result = run(LockArgs { - action: Some(LockAction::Status), + action: Some(LockAction::Status { json: false }), timeout: None, reason: None, push: false, @@ -1388,7 +1454,7 @@ mod tests { let _ = std::env::set_current_dir(dir.path()); lock(None, None, &["push"]).unwrap(); - assert!(status().is_ok()); + assert!(status(false).is_ok()); let content = std::fs::read_to_string(dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); assert!(content.contains("\"operations\":[\"push\"]")); @@ -1397,4 +1463,225 @@ mod tests { let _ = std::env::set_current_dir(orig); } } + + // ── status_json() ──────────────────────────────────────────────────────── + // + // These exercise `status_json` directly (never `status(true)`, which + // calls `process::exit` and would kill the test binary). Exit-code + // behavior is covered by the subprocess-based integration tests instead. + + /// Extracts top-level `"key":` names from a flat (no nested objects) + /// single-line JSON object, so tests can assert the exact key set + /// without pulling in a JSON parsing dependency. A `"key"` is only + /// counted if immediately followed by `:` — array elements like + /// `"commit"` inside `"operations":[...]` are never followed by `:` and + /// so are correctly excluded. + fn json_object_keys(json: &str) -> Vec { + let mut keys = Vec::new(); + let mut i = 0; + while i < json.len() { + if json.as_bytes()[i] == b'"' { + if let Some(end) = json[i + 1..].find('"') { + let candidate = &json[i + 1..i + 1 + end]; + let after = i + 1 + end + 1; + if json[after..].starts_with(':') { + keys.push(candidate.to_string()); + i = after + 1; + continue; + } + } + } + i += 1; + } + keys + } + + const EXPECTED_STATUS_JSON_KEYS: [&str; 6] = [ + "active", + "operations", + "locked_at", + "expires_at", + "reason", + "expired", + ]; + + fn assert_exact_key_set(json: &str) { + let mut keys = json_object_keys(json); + keys.sort(); + let mut expected: Vec = EXPECTED_STATUS_JSON_KEYS + .iter() + .map(|s| s.to_string()) + .collect(); + expected.sort(); + assert_eq!(keys, expected, "json was: {json}"); + } + + #[test] + fn status_json_exact_key_set_when_no_lock() { + let (json, active) = status_json(&LockState::None, "2026-01-01T00:00:00Z"); + assert!(!active); + assert_exact_key_set(&json); + } + + #[test] + fn status_json_exact_key_set_when_malformed() { + let (json, active) = status_json(&LockState::Malformed, "2026-01-01T00:00:00Z"); + assert!(!active); + assert_exact_key_set(&json); + } + + #[test] + fn status_json_exact_key_set_when_locked() { + let lf = LockFile { + locked_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: Some("2026-01-01T01:00:00Z".to_string()), + reason: "agent session".to_string(), + operations: vec!["commit".to_string()], + }; + let (json, active) = status_json(&LockState::Present(lf), "2026-01-01T00:05:00Z"); + assert!(active); + assert_exact_key_set(&json); + } + + #[test] + fn status_json_no_lock_reports_inactive_and_null_fields() { + let (json, active) = status_json(&LockState::None, "2026-01-01T00:00:00Z"); + assert!(!active); + assert!(json.contains("\"active\":false"), "json was: {json}"); + assert!(json.contains("\"operations\":[]"), "json was: {json}"); + assert!(json.contains("\"locked_at\":null"), "json was: {json}"); + assert!(json.contains("\"expires_at\":null"), "json was: {json}"); + assert!(json.contains("\"reason\":null"), "json was: {json}"); + assert!(json.contains("\"expired\":false"), "json was: {json}"); + } + + #[test] + fn status_json_malformed_reports_same_as_no_lock() { + let (none_json, none_active) = status_json(&LockState::None, "2026-01-01T00:00:00Z"); + let (malformed_json, malformed_active) = + status_json(&LockState::Malformed, "2026-01-01T00:00:00Z"); + assert_eq!(none_json, malformed_json); + assert_eq!(none_active, malformed_active); + } + + #[test] + fn status_json_active_lock_reports_true_and_fields() { + let lf = LockFile { + locked_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: Some("2026-01-01T01:00:00Z".to_string()), + reason: "agent session".to_string(), + operations: vec!["commit".to_string(), "push".to_string()], + }; + let (json, active) = status_json(&LockState::Present(lf), "2026-01-01T00:05:00Z"); + assert!(active); + assert!(json.contains("\"active\":true"), "json was: {json}"); + assert!( + json.contains("\"operations\":[\"commit\",\"push\"]"), + "json was: {json}" + ); + assert!( + json.contains("\"locked_at\":\"2026-01-01T00:00:00Z\""), + "json was: {json}" + ); + assert!( + json.contains("\"expires_at\":\"2026-01-01T01:00:00Z\""), + "json was: {json}" + ); + assert!( + json.contains("\"reason\":\"agent session\""), + "json was: {json}" + ); + assert!(json.contains("\"expired\":false"), "json was: {json}"); + } + + #[test] + fn status_json_never_expiring_lock_reports_null_expiry() { + let lf = LockFile { + locked_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: None, + reason: "agent session".to_string(), + operations: vec!["commit".to_string()], + }; + let (json, active) = status_json(&LockState::Present(lf), "2026-01-01T00:05:00Z"); + assert!(active); + assert!(json.contains("\"expires_at\":null"), "json was: {json}"); + assert!(json.contains("\"expired\":false"), "json was: {json}"); + } + + #[test] + fn status_json_expired_lock_reports_inactive_but_keeps_operations() { + let lf = LockFile { + locked_at: "2000-01-01T00:00:00Z".to_string(), + expires_at: Some("2000-01-01T00:01:00Z".to_string()), + reason: "long expired".to_string(), + operations: vec!["commit".to_string(), "push".to_string()], + }; + let (json, active) = status_json(&LockState::Present(lf), "2026-01-01T00:00:00Z"); + assert!(!active); + assert!(json.contains("\"active\":false"), "json was: {json}"); + assert!(json.contains("\"expired\":true"), "json was: {json}"); + assert!( + json.contains("\"operations\":[\"commit\",\"push\"]"), + "json was: {json}" + ); + } + + #[test] + fn status_json_empty_operations_reports_inactive() { + let lf = LockFile { + locked_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: None, + reason: "x".to_string(), + operations: vec![], + }; + let (json, active) = status_json(&LockState::Present(lf), "2026-01-01T00:00:00Z"); + assert!(!active); + assert!(json.contains("\"active\":false"), "json was: {json}"); + } + + // ── read_lock_state() ──────────────────────────────────────────────────── + + #[serial] + #[test] + fn read_lock_state_none_when_file_missing() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + + let state = read_lock_state(&dir.path().join(".git").join(LOCK_FILE_NAME)).unwrap(); + assert!(matches!(state, LockState::None)); + } + + #[serial] + #[test] + fn read_lock_state_malformed_when_content_unparseable() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let path = dir.path().join(".git").join(LOCK_FILE_NAME); + std::fs::write(&path, "not json").unwrap(); + + let state = read_lock_state(&path).unwrap(); + assert!(matches!(state, LockState::Malformed)); + } + + #[serial] + #[test] + fn read_lock_state_present_when_valid() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + let path = dir.path().join(".git").join(LOCK_FILE_NAME); + lock_write_fixture(&path, "commit"); + + let state = read_lock_state(&path).unwrap(); + assert!(matches!(state, LockState::Present(_))); + } + + fn lock_write_fixture(path: &Path, op: &str) { + let lf = LockFile { + locked_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: None, + reason: "x".to_string(), + operations: vec![op.to_string()], + }; + std::fs::write(path, lf.to_json()).unwrap(); + } } diff --git a/tests/integration.rs b/tests/integration.rs index 06f0101..0c6291d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -860,3 +860,154 @@ fn push_fixture_preserves_existing_user_pre_push_hook() { assert!(ok); assert!(msg.contains("user-push-hook-ran"), "message was: {msg}"); } + +// ═══════════════════════════════════════════════════════════════════════════ +// `lock status --json` integration tests +// ═══════════════════════════════════════════════════════════════════════════ +// +// Exit-code assertions live here (real subprocess) rather than in the crate's +// unit tests, since `lock status --json` calls `process::exit` and running +// that in-process would kill the test binary. + +#[test] +fn lock_status_json_exit_code_zero_when_unlocked() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + let out = Command::new(&binary) + .args(["lock", "status", "--json"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock status --json"); + + assert!( + out.status.success(), + "expected exit 0 when no lock is active" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("\"active\":false"), "stdout was: {stdout}"); + assert!(stdout.contains("\"expired\":false"), "stdout was: {stdout}"); + assert!(stdout.contains("\"operations\":[]"), "stdout was: {stdout}"); + assert!( + stdout.contains("\"locked_at\":null"), + "stdout was: {stdout}" + ); +} + +#[test] +fn lock_status_json_exit_code_nonzero_when_locked() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock", "--reason", "agent session"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(lock_out.status.success()); + + let out = Command::new(&binary) + .args(["lock", "status", "--json"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock status --json"); + + assert!( + !out.status.success(), + "expected non-zero exit when a lock is active" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("\"active\":true"), "stdout was: {stdout}"); + assert!( + stdout.contains("\"reason\":\"agent session\""), + "stdout was: {stdout}" + ); + assert!( + stdout.contains("\"operations\":[\"commit\"]"), + "stdout was: {stdout}" + ); +} + +#[test] +fn lock_status_json_exit_code_zero_when_expired() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + let lock_path = dir.path().join(".git").join("gitkit.lock"); + std::fs::write( + &lock_path, + r#"{"locked_at":"2000-01-01T00:00:00Z","expires_at":"2000-01-01T00:01:00Z","reason":"stale","operations":["commit"]}"#, + ) + .unwrap(); + + let out = Command::new(&binary) + .args(["lock", "status", "--json"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock status --json"); + + assert!( + out.status.success(), + "expected exit 0 when the lock has expired" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("\"active\":false"), "stdout was: {stdout}"); + assert!(stdout.contains("\"expired\":true"), "stdout was: {stdout}"); + assert!( + stdout.contains("\"operations\":[\"commit\"]"), + "stdout was: {stdout}" + ); +} + +#[test] +fn lock_status_json_exit_code_zero_when_malformed() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + let lock_path = dir.path().join(".git").join("gitkit.lock"); + std::fs::write(&lock_path, "not json at all {{{").unwrap(); + + let out = Command::new(&binary) + .args(["lock", "status", "--json"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock status --json"); + + assert!( + out.status.success(), + "a malformed lock file must report unlocked, not fail the caller" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("\"active\":false"), "stdout was: {stdout}"); +} + +#[test] +fn lock_status_json_works_from_a_subdirectory() { + let dir = TempDir::new().unwrap(); + init_git_repo(dir.path()); + let binary = gitkit_binary(); + + let lock_out = Command::new(&binary) + .args(["lock"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run gitkit lock"); + assert!(lock_out.status.success()); + + let subdir = dir.path().join("nested").join("deeper"); + std::fs::create_dir_all(&subdir).unwrap(); + + let out = Command::new(&binary) + .args(["lock", "status", "--json"]) + .current_dir(&subdir) + .output() + .expect("Failed to run gitkit lock status --json from a subdirectory"); + + assert!(!out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("\"active\":true"), "stdout was: {stdout}"); +} From 6518a0b0e34fd1d619177abbbdc982e73f31bfbb Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 11 Aug 2026 23:09:30 -0500 Subject: [PATCH 04/20] feat(autoupdate): add version check and self-update mechanism --- Cargo.lock | 59 ++++++ Cargo.toml | 3 + src/autoupdate/install.rs | 425 ++++++++++++++++++++++++++++++++++++++ src/autoupdate/mod.rs | 141 +++++++++++++ src/main.rs | 2 + tests/integration.rs | 84 ++++++++ 6 files changed, 714 insertions(+) create mode 100644 src/autoupdate/install.rs create mode 100644 src/autoupdate/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 1deb097..9586285 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -329,9 +339,12 @@ version = "0.4.0" dependencies = [ "anyhow", "clap", + "flate2", "inquire", "serde", + "serde_json", "serial_test", + "tar", "tempfile", "toml", "ureq", @@ -485,6 +498,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.186" @@ -742,6 +761,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -871,6 +903,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1223,6 +1266,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -1305,3 +1358,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 13aadd8..68964b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,10 @@ path = "src/main.rs" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } +flate2 = "1" serde = { version = "1", features = ["derive"] } +serde_json = "1" +tar = "0.4" toml = "0.8" ureq = "2" diff --git a/src/autoupdate/install.rs b/src/autoupdate/install.rs new file mode 100644 index 0000000..964e01c --- /dev/null +++ b/src/autoupdate/install.rs @@ -0,0 +1,425 @@ +//! Binary replacement mechanics: detects a cargo-managed install and, +//! otherwise, downloads, verifies, and swaps in the new binary. +//! +//! Never writes to a hardcoded install directory. Resolves +//! `std::env::current_exe()` and either replaces that file in place or, +//! when it is managed by `cargo install`, leaves it untouched and tells +//! the user to run `cargo install --force` instead. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +use super::GITHUB_REPO; + +enum Outcome { + Updated, + CargoManaged, +} + +/// Entry point, called after the user confirms the "Install now?" prompt. +pub(super) fn run(latest_tag: &str) { + match install(latest_tag) { + Ok(Outcome::Updated) => { + println!(" \x1b[32m✓\x1b[0m Updated! Restart your terminal to use the new version."); + } + Ok(Outcome::CargoManaged) => { + println!(" ℹ gitkit was installed with cargo — the auto-updater won't touch it."); + println!(" Run this instead:"); + println!(); + println!(" cargo install --force gitkit"); + println!(); + } + Err(e) => { + eprintln!(" ⚠ Update failed: {e:#}"); + } + } +} + +fn install(latest_tag: &str) -> Result { + let current_exe = + std::env::current_exe().context("failed to resolve current executable path")?; + + if is_cargo_managed(¤t_exe) { + return Ok(Outcome::CargoManaged); + } + + let dir = current_exe + .parent() + .context("executable path has no parent directory")?; + let file_name = current_exe + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("gitkit"); + let tmp_path = dir.join(format!(".{file_name}.update")); + + let (arch, os) = detect_platform()?; + update_binary_at(&tmp_path, ¤t_exe, |out| { + download_and_extract(latest_tag, arch, os, out) + })?; + + Ok(Outcome::Updated) +} + +// ── Cargo-managed detection ───────────────────────────────────── + +fn resolve_cargo_root( + install_root_env: Option, + cargo_home_env: Option, + home_dir: Option, +) -> Option { + if let Some(root) = install_root_env.filter(|s| !s.is_empty()) { + return Some(PathBuf::from(root)); + } + if let Some(home) = cargo_home_env.filter(|s| !s.is_empty()) { + return Some(PathBuf::from(home)); + } + home_dir.map(|h| h.join(".cargo")) +} + +fn home_dir() -> Option { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .ok() + .map(PathBuf::from) +} + +fn cargo_install_root() -> Option { + resolve_cargo_root( + std::env::var("CARGO_INSTALL_ROOT").ok(), + std::env::var("CARGO_HOME").ok(), + home_dir(), + ) +} + +fn is_cargo_managed_with_root(exe_path: &Path, root: Option) -> bool { + let Some(root) = root else { + return false; + }; + let bin_dir = root.join("bin"); + match (exe_path.canonicalize(), bin_dir.canonicalize()) { + (Ok(exe), Ok(bin)) => exe.starts_with(bin), + _ => false, + } +} + +fn is_cargo_managed(exe_path: &Path) -> bool { + is_cargo_managed_with_root(exe_path, cargo_install_root()) +} + +// ── Download + replace ────────────────────────────────────────── + +/// Fetches into `tmp_path` via `fetch`, makes it executable, and atomically +/// replaces `target` with it. On any failure, `tmp_path` is removed and +/// `target` is left untouched. `tmp_path` must sit beside `target` (same +/// directory) so the replace below never crosses a filesystem boundary. +fn update_binary_at( + tmp_path: &Path, + target: &Path, + fetch: impl FnOnce(&Path) -> Result<()>, +) -> Result<()> { + let result = (|| -> Result<()> { + fetch(tmp_path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(tmp_path, std::fs::Permissions::from_mode(0o755)) + .with_context(|| { + format!( + "failed to set executable permission on {}", + tmp_path.display() + ) + })?; + } + + replace_binary(tmp_path, target) + })(); + + if result.is_err() { + let _ = std::fs::remove_file(tmp_path); + } + + result +} + +fn replace_binary(tmp_path: &Path, target: &Path) -> Result<()> { + // `tmp_path` sits beside `target`, so this is a same-filesystem rename + // and should always succeed; the copy fallback only guards against the + // rare case of a bind mount or similar splitting the directory across + // devices. + if std::fs::rename(tmp_path, target).is_err() { + std::fs::copy(tmp_path, target) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + anyhow::anyhow!( + "permission denied replacing {} — check that the containing directory is writable by the current user", + target.display() + ) + } else { + anyhow::Error::new(e).context(format!("failed to replace {} (copy fallback)", target.display())) + } + })?; + let _ = std::fs::remove_file(tmp_path); + } + Ok(()) +} + +fn download_and_extract(tag: &str, arch: &str, os: &str, output: &Path) -> Result<()> { + let archive_name = format!("gitkit-{tag}-{arch}-{os}.tar.gz"); + let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}"); + + let resp = ureq::get(&url) + .set("User-Agent", "gitkit-autoupdate") + .call() + .with_context(|| format!("failed to download {url}"))?; + + extract_binary(resp.into_reader(), output) +} + +fn extract_binary(reader: impl std::io::Read, output: &Path) -> Result<()> { + let decoder = flate2::read::GzDecoder::new(reader); + let mut archive = tar::Archive::new(decoder); + + let mut found = false; + for entry in archive + .entries() + .context("corrupt archive: failed to read entries")? + { + let mut entry = entry.context("corrupt archive: failed to read entry")?; + let path = entry + .path() + .context("corrupt archive: invalid entry path")?; + if path.file_name().is_some_and(|n| n == "gitkit") { + entry + .unpack(output) + .context("failed to extract binary from archive")?; + found = true; + break; + } + } + + if !found { + anyhow::bail!("binary not found in archive"); + } + + let meta = std::fs::metadata(output) + .with_context(|| format!("failed to stat extracted binary at {}", output.display()))?; + if meta.len() == 0 { + let _ = std::fs::remove_file(output); + anyhow::bail!("extracted binary is empty"); + } + + Ok(()) +} + +fn detect_platform() -> Result<(&'static str, &'static str)> { + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + other => anyhow::bail!("unsupported architecture: {other}"), + }; + let os = match std::env::consts::OS { + "linux" => "unknown-linux-musl", + "macos" => "apple-darwin", + other => anyhow::bail!("unsupported OS: {other}"), + }; + Ok((arch, os)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec { + let mut tar_bytes = Vec::new(); + { + let mut builder = tar::Builder::new(&mut tar_bytes); + for (name, content) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, name, *content).unwrap(); + } + builder.finish().unwrap(); + } + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gz.write_all(&tar_bytes).unwrap(); + gz.finish().unwrap() + } + + #[test] + fn extract_binary_finds_named_entry() { + let archive = make_tar_gz(&[("gitkit", b"fake-binary-contents")]); + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + extract_binary(std::io::Cursor::new(archive), &output).unwrap(); + assert_eq!(std::fs::read(&output).unwrap(), b"fake-binary-contents"); + } + + #[test] + fn extract_binary_rejects_missing_entry() { + let archive = make_tar_gz(&[("other-file", b"contents")]); + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + let err = extract_binary(std::io::Cursor::new(archive), &output).unwrap_err(); + assert!(err.to_string().contains("not found")); + assert!(!output.exists()); + } + + #[test] + fn extract_binary_rejects_empty_binary() { + let archive = make_tar_gz(&[("gitkit", b"")]); + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + let err = extract_binary(std::io::Cursor::new(archive), &output).unwrap_err(); + assert!(err.to_string().contains("empty")); + assert!(!output.exists()); + } + + #[test] + fn extract_binary_rejects_corrupt_archive() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + let result = extract_binary(std::io::Cursor::new(b"not a gzip stream".to_vec()), &output); + assert!(result.is_err()); + assert!(!output.exists()); + } + + #[test] + fn update_binary_at_replaces_target_and_sets_executable() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("gitkit"); + std::fs::write(&target, b"old-binary").unwrap(); + let tmp_path = dir.path().join(".gitkit.update"); + + update_binary_at(&tmp_path, &target, |out| { + std::fs::write(out, b"new-binary")?; + Ok(()) + }) + .unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new-binary"); + assert!(!tmp_path.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&target).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111); + } + } + + #[test] + fn update_binary_at_leaves_target_untouched_when_fetch_fails() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("gitkit"); + std::fs::write(&target, b"original-binary").unwrap(); + let tmp_path = dir.path().join(".gitkit.update"); + + let result = update_binary_at(&tmp_path, &target, |_out| { + anyhow::bail!("simulated download failure") + }); + + assert!(result.is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"original-binary"); + assert!(!tmp_path.exists()); + } + + #[test] + fn update_binary_at_cleans_up_tmp_file_when_extraction_writes_then_fails() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("gitkit"); + std::fs::write(&target, b"original-binary").unwrap(); + let tmp_path = dir.path().join(".gitkit.update"); + + let result = update_binary_at(&tmp_path, &target, |out| { + std::fs::write(out, b"partial-garbage")?; + anyhow::bail!("corrupt archive") + }); + + assert!(result.is_err()); + assert!(!tmp_path.exists()); + assert_eq!(std::fs::read(&target).unwrap(), b"original-binary"); + } + + #[test] + fn resolve_cargo_root_prefers_install_root() { + let root = resolve_cargo_root( + Some("/opt/install-root".to_string()), + Some("/opt/cargo-home".to_string()), + Some(PathBuf::from("/home/user")), + ); + assert_eq!(root, Some(PathBuf::from("/opt/install-root"))); + } + + #[test] + fn resolve_cargo_root_falls_back_to_cargo_home() { + let root = resolve_cargo_root( + None, + Some("/opt/cargo-home".to_string()), + Some(PathBuf::from("/home/user")), + ); + assert_eq!(root, Some(PathBuf::from("/opt/cargo-home"))); + } + + #[test] + fn resolve_cargo_root_falls_back_to_home_dot_cargo() { + let root = resolve_cargo_root(None, None, Some(PathBuf::from("/home/user"))); + assert_eq!(root, Some(PathBuf::from("/home/user/.cargo"))); + } + + #[test] + fn resolve_cargo_root_ignores_empty_env_values() { + let root = resolve_cargo_root( + Some(String::new()), + Some(String::new()), + Some(PathBuf::from("/home/user")), + ); + assert_eq!(root, Some(PathBuf::from("/home/user/.cargo"))); + } + + #[test] + fn is_cargo_managed_detects_path_inside_root() { + let dir = tempfile::tempdir().unwrap(); + let cargo_root = dir.path().join("cargo"); + let bin_dir = cargo_root.join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let exe = bin_dir.join("gitkit"); + std::fs::write(&exe, b"binary").unwrap(); + + assert!(is_cargo_managed_with_root(&exe, Some(cargo_root))); + } + + #[test] + fn is_cargo_managed_rejects_path_outside_root() { + let dir = tempfile::tempdir().unwrap(); + let cargo_root = dir.path().join("cargo"); + std::fs::create_dir_all(cargo_root.join("bin")).unwrap(); + let other_dir = dir.path().join("elsewhere"); + std::fs::create_dir_all(&other_dir).unwrap(); + let exe = other_dir.join("gitkit"); + std::fs::write(&exe, b"binary").unwrap(); + + assert!(!is_cargo_managed_with_root(&exe, Some(cargo_root))); + } + + #[test] + fn is_cargo_managed_treats_uncanonicalizable_path_as_not_cargo() { + let dir = tempfile::tempdir().unwrap(); + let cargo_root = dir.path().join("cargo"); + std::fs::create_dir_all(cargo_root.join("bin")).unwrap(); + let missing_exe = dir.path().join("does-not-exist"); + + assert!(!is_cargo_managed_with_root(&missing_exe, Some(cargo_root))); + } + + #[test] + fn is_cargo_managed_with_no_root_is_false() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("gitkit"); + std::fs::write(&exe, b"binary").unwrap(); + + assert!(!is_cargo_managed_with_root(&exe, None)); + } +} diff --git a/src/autoupdate/mod.rs b/src/autoupdate/mod.rs new file mode 100644 index 0000000..c8e6132 --- /dev/null +++ b/src/autoupdate/mod.rs @@ -0,0 +1,141 @@ +//! Update check: looks for a newer GitHub release and, on confirmation, +//! hands off to [`install`] to replace the running binary. +//! +//! Called once from `main`, before any subcommand runs — gitkit's own +//! binary is never invoked from inside a git hook (the hooks it installs +//! are plain POSIX `sh` scripts), so this is never on a hook path. +//! Every failure here returns silently: a version check must never +//! interrupt the user's actual work. + +use std::io::IsTerminal; +use std::time::Duration; + +use serde::Deserialize; + +mod install; + +const GITHUB_REPO: &str = "UniverLab/gitkit"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Deserialize)] +struct GithubRelease { + tag_name: String, +} + +/// Entry point. Opt out with `GITKIT_NO_UPDATE_CHECK` (any value). +pub fn check_for_update() { + if update_check_disabled(std::env::var("GITKIT_NO_UPDATE_CHECK").ok()) { + return; + } + + let Some(latest) = fetch_latest_tag() else { + return; + }; + + let current = format!("v{}", env!("CARGO_PKG_VERSION")); + if !is_newer(¤t, &latest) { + return; + } + + // A confirm prompt in a script or CI would hang it — skip straight past. + if !std::io::stdin().is_terminal() { + return; + } + + println!(" \x1b[33m⬆ Update available:\x1b[0m {current} → {latest}"); + let Ok(install) = inquire::Confirm::new("Install now?") + .with_default(true) + .prompt() + else { + return; + }; + if !install { + println!(); + return; + } + + install::run(&latest); +} + +fn fetch_latest_tag() -> Option { + let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest"); + let resp = ureq::get(&url) + .timeout(HTTP_TIMEOUT) + .set("User-Agent", "gitkit-autoupdate") + .call() + .ok()?; + let body = resp.into_string().ok()?; + let release: GithubRelease = serde_json::from_str(&body).ok()?; + if release.tag_name.is_empty() { + return None; + } + Some(release.tag_name) +} + +fn update_check_disabled(opt_out: Option) -> bool { + opt_out.is_some() +} + +fn is_newer(current: &str, latest: &str) -> bool { + let parse = |v: &str| -> (u64, u64, u64) { + let v = v.trim_start_matches('v'); + let p: Vec = v.split('.').filter_map(|s| s.parse().ok()).collect(); + ( + *p.first().unwrap_or(&0), + *p.get(1).unwrap_or(&0), + *p.get(2).unwrap_or(&0), + ) + }; + parse(latest) > parse(current) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_minor_version() { + assert!(is_newer("v0.9.0", "v0.10.0")); + } + + #[test] + fn is_newer_major_version() { + assert!(is_newer("v0.99.99", "v1.0.0")); + } + + #[test] + fn is_newer_equal_versions_not_newer() { + assert!(!is_newer("v0.4.0", "v0.4.0")); + } + + #[test] + fn is_newer_older_is_not_newer() { + assert!(!is_newer("v1.0.0", "v0.9.0")); + } + + #[test] + fn is_newer_handles_missing_v_prefix_on_current() { + assert!(is_newer("0.4.0", "v0.5.0")); + } + + #[test] + fn is_newer_handles_missing_v_prefix_on_latest() { + assert!(is_newer("v0.4.0", "0.5.0")); + } + + #[test] + fn is_newer_handles_missing_v_prefix_on_both() { + assert!(is_newer("0.4.0", "0.5.0")); + } + + #[test] + fn update_check_disabled_when_var_is_set() { + assert!(update_check_disabled(Some(String::new()))); + assert!(update_check_disabled(Some("1".to_string()))); + } + + #[test] + fn update_check_not_disabled_when_var_is_absent() { + assert!(!update_check_disabled(None)); + } +} diff --git a/src/main.rs b/src/main.rs index ded2c57..10d245b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; mod attributes; +mod autoupdate; mod builds; mod clone; mod config; @@ -65,6 +66,7 @@ enum Command { fn main() -> Result<()> { let cli = Cli::parse(); + autoupdate::check_for_update(); match cli.command { Some(Command::Init) | None => init::run(), Some(Command::Status) => status::run(), diff --git a/tests/integration.rs b/tests/integration.rs index 0c6291d..db03539 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -20,6 +20,8 @@ fn gitkit_binary() -> std::path::PathBuf { fn run_gitkit(args: &[&str]) -> (bool, String) { let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(args) .current_dir(env!("CARGO_MANIFEST_DIR")) .output() @@ -111,6 +113,8 @@ fn cli_status_outside_repo() { let dir = TempDir::new().unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["status"]) .current_dir(dir.path()) .output() @@ -124,6 +128,8 @@ fn cli_hooks_list_outside_repo() { let dir = TempDir::new().unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "list"]) .current_dir(dir.path()) .output() @@ -139,6 +145,8 @@ fn cli_build_list_empty() { let dir = TempDir::new().unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["build", "list"]) .current_dir(dir.path()) .output() @@ -157,6 +165,8 @@ fn cli_hooks_add_invalid_builtin() { std::fs::create_dir_all(dir.path().join(".git").join("hooks")).unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "add", "--yes", "nonexistent-builtin"]) .current_dir(dir.path()) .output() @@ -179,6 +189,8 @@ fn cli_hooks_add_custom_hook() { std::fs::create_dir_all(dir.path().join(".git").join("hooks")).unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "add", "--yes", "pre-push", "echo test"]) .current_dir(dir.path()) .output() @@ -203,6 +215,8 @@ fn cli_hooks_add_builtin_conventional_commits() { std::fs::create_dir_all(dir.path().join(".git").join("hooks")).unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "add", "--yes", "conventional-commits"]) .current_dir(dir.path()) .output() @@ -227,6 +241,8 @@ fn cli_hooks_remove_installed_hook() { let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "remove", "--yes", "pre-push"]) .current_dir(dir.path()) .output() @@ -248,6 +264,8 @@ fn cli_hooks_remove_nonexistent_hook() { let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "remove", "--yes", "nonexistent-hook"]) .current_dir(dir.path()) .output() @@ -269,6 +287,8 @@ fn cli_hooks_show_installed_hook() { let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "show", "pre-push"]) .current_dir(dir.path()) .output() @@ -286,6 +306,8 @@ fn cli_hooks_show_nonexistent_hook() { let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "show", "nonexistent"]) .current_dir(dir.path()) .output() @@ -300,6 +322,8 @@ fn cli_hooks_add_invalid_hook_name() { std::fs::create_dir_all(dir.path().join(".git").join("hooks")).unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "add", "--yes", "not-a-real-hook", "echo hi"]) .current_dir(dir.path()) .output() @@ -318,6 +342,8 @@ fn cli_hooks_add_custom_hook_creates_executable() { std::fs::create_dir_all(dir.path().join(".git").join("hooks")).unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["hooks", "add", "--yes", "pre-commit", "echo hello"]) .current_dir(dir.path()) .output() @@ -339,6 +365,8 @@ fn cli_hooks_add_with_dry_run() { std::fs::create_dir_all(dir.path().join(".git").join("hooks")).unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args([ "hooks", "add", @@ -368,6 +396,8 @@ fn cli_ignore_add_dry_run() { std::fs::create_dir(dir.path().join(".git")).unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["ignore", "add", "--yes", "--dry-run", "rust"]) .current_dir(dir.path()) .output() @@ -383,6 +413,8 @@ fn cli_attributes_init_dry_run() { std::fs::create_dir(dir.path().join(".git")).unwrap(); let binary = gitkit_binary(); let output = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["attributes", "init", "--yes", "--dry-run"]) .current_dir(dir.path()) .output() @@ -449,6 +481,8 @@ fn lock_fixture_commit_succeeds_unlocked_fails_locked_succeeds_after_unlock() { // Lock: commit fails and names the reason + how to unlock. let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--reason", "Agent session active"]) .current_dir(dir.path()) .output() @@ -462,6 +496,8 @@ fn lock_fixture_commit_succeeds_unlocked_fails_locked_succeeds_after_unlock() { // Unlock: commit succeeds again. let unlock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["unlock"]) .current_dir(dir.path()) .output() @@ -479,6 +515,8 @@ fn lock_fixture_expired_lock_does_not_block_commit() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--timeout", "30m"]) .current_dir(dir.path()) .output() @@ -502,6 +540,8 @@ fn lock_fixture_expired_lock_does_not_block_commit() { // status should report the lock as expired. let status_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "status"]) .current_dir(dir.path()) .output() @@ -518,6 +558,8 @@ fn lock_fixture_malformed_lock_file_fails_open() { // Install the hook via a real lock, then corrupt the lock file. let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock"]) .current_dir(dir.path()) .output() @@ -539,6 +581,8 @@ fn lock_fixture_locking_twice_is_idempotent() { let run_lock = |reason: &str| { let out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--reason", reason]) .current_dir(dir.path()) .output() @@ -580,6 +624,8 @@ fn lock_fixture_preserves_existing_user_pre_commit_hook() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock"]) .current_dir(dir.path()) .output() @@ -588,6 +634,8 @@ fn lock_fixture_preserves_existing_user_pre_commit_hook() { assert!(hooks_dir.join("pre-commit.gitkit-orig").exists()); let unlock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["unlock"]) .current_dir(dir.path()) .output() @@ -663,6 +711,8 @@ fn push_fixture_push_succeeds_unlocked_fails_locked_succeeds_after_unlock() { // Lock --push: push fails and names the reason + how to unlock. let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--push", "--reason", "Agent session active"]) .current_dir(dir.path()) .output() @@ -679,6 +729,8 @@ fn push_fixture_push_succeeds_unlocked_fails_locked_succeeds_after_unlock() { // Unlock: push succeeds again. let unlock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["unlock"]) .current_dir(dir.path()) .output() @@ -696,6 +748,8 @@ fn push_fixture_expired_lock_does_not_block_push() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--push", "--timeout", "30m"]) .current_dir(dir.path()) .output() @@ -718,6 +772,8 @@ fn push_fixture_expired_lock_does_not_block_push() { assert!(ok, "push should succeed once the lock has expired"); let status_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "status"]) .current_dir(dir.path()) .output() @@ -733,6 +789,8 @@ fn push_fixture_malformed_lock_file_fails_open() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--push"]) .current_dir(dir.path()) .output() @@ -754,6 +812,8 @@ fn push_fixture_commit_lock_alone_does_not_block_push() { // Only the commit lock is active - push must remain unblocked. let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock"]) .current_dir(dir.path()) .output() @@ -774,6 +834,8 @@ fn push_fixture_all_locks_both_commit_and_push() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--all", "--reason", "full lockdown"]) .current_dir(dir.path()) .output() @@ -795,6 +857,8 @@ fn push_fixture_adding_push_lock_preserves_commit_lock_reason() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--reason", "original session"]) .current_dir(dir.path()) .output() @@ -803,6 +867,8 @@ fn push_fixture_adding_push_lock_preserves_commit_lock_reason() { // Extend to push without a new --reason. let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--push"]) .current_dir(dir.path()) .output() @@ -837,6 +903,8 @@ fn push_fixture_preserves_existing_user_pre_push_hook() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--push"]) .current_dir(dir.path()) .output() @@ -845,6 +913,8 @@ fn push_fixture_preserves_existing_user_pre_push_hook() { assert!(hooks_dir.join("pre-push.gitkit-orig").exists()); let unlock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["unlock"]) .current_dir(dir.path()) .output() @@ -876,6 +946,8 @@ fn lock_status_json_exit_code_zero_when_unlocked() { let binary = gitkit_binary(); let out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "status", "--json"]) .current_dir(dir.path()) .output() @@ -902,6 +974,8 @@ fn lock_status_json_exit_code_nonzero_when_locked() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "--reason", "agent session"]) .current_dir(dir.path()) .output() @@ -909,6 +983,8 @@ fn lock_status_json_exit_code_nonzero_when_locked() { assert!(lock_out.status.success()); let out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "status", "--json"]) .current_dir(dir.path()) .output() @@ -944,6 +1020,8 @@ fn lock_status_json_exit_code_zero_when_expired() { .unwrap(); let out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "status", "--json"]) .current_dir(dir.path()) .output() @@ -972,6 +1050,8 @@ fn lock_status_json_exit_code_zero_when_malformed() { std::fs::write(&lock_path, "not json at all {{{").unwrap(); let out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "status", "--json"]) .current_dir(dir.path()) .output() @@ -992,6 +1072,8 @@ fn lock_status_json_works_from_a_subdirectory() { let binary = gitkit_binary(); let lock_out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock"]) .current_dir(dir.path()) .output() @@ -1002,6 +1084,8 @@ fn lock_status_json_works_from_a_subdirectory() { std::fs::create_dir_all(&subdir).unwrap(); let out = Command::new(&binary) + // Never let a test hit the network via the update check. + .env("GITKIT_NO_UPDATE_CHECK", "1") .args(["lock", "status", "--json"]) .current_dir(&subdir) .output() From be3dbc9045d83444e796cfd7d8df4a886a44947c Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Tue, 11 Aug 2026 23:13:37 -0500 Subject: [PATCH 05/20] chore: bump version to 0.5.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9586285..f6e55ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,7 +335,7 @@ dependencies = [ [[package]] name = "gitkit" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 68964b3..74290b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitkit" -version = "0.4.0" +version = "0.5.0" edition = "2021" description = "Standalone CLI for configuring git repos — hooks, .gitignore, and .gitattributes" license = "MIT" From bae72c2d79a6579aa3d837a880226d18a5476fe6 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 12 Aug 2026 08:06:15 -0500 Subject: [PATCH 06/20] docs: Add homepage to Cargo.toml and README Add homepage field to Cargo.toml linking to https://univerlab.org/gitkit. crates.io renders this as a clickable link in the sidebar. Add corresponding link in README for discoverable visitors. --- Cargo.toml | 1 + README.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 74290b6..4deb0cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" description = "Standalone CLI for configuring git repos — hooks, .gitignore, and .gitattributes" license = "MIT" repository = "https://github.com/UniverLab/gitkit" +homepage = "https://univerlab.org/gitkit" keywords = ["git", "hooks", "cli", "gitignore", "gitattributes"] categories = ["command-line-utilities", "development-tools"] diff --git a/README.md b/README.md index 758976d..f7cea11 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ License

+

+ Visit the website +

+ Set up a git repo the way you actually work — one guided flow for hooks, `.gitignore`, `.gitattributes`, and git config. One binary, no Node.js, no Python, no runtime dependencies. --- From c1f434a18c13886cc719fc9fa3369d99e310e360 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 12 Aug 2026 08:07:23 -0500 Subject: [PATCH 07/20] docs: Document lock and self-update features Add Repository locks and Version check & self-update features to README Features section. Update CLI Reference to document --push, --all flags for gitkit lock, and --json flag for gitkit lock status. Repository locks block commits and pushes during agent sessions via pre-commit and pre-push hooks. Version check queries GitHub for newer releases and can be disabled with GITKIT_NO_UPDATE_CHECK environment variable. --- README.md | 2 ++ docs/cli-reference.md | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f7cea11..110db3b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ Set up a git repo the way you actually work — one guided flow for hooks, `.git - **🧩 Ignore and attribute presets** — Browse built-in and gitignore.io templates, then apply line-ending or binary presets. - **⚙️ Curated git config** — Apply practical presets with `--global` or `--local` scope, with idempotency detection. - **💾 Save & reuse builds** — Save configurations and apply them to any project with one command. +- **🔒 Repository locks** — Block commits and pushes during agent sessions with `gitkit lock` / `gitkit unlock` — useful when autonomous agents are editing the repo. +- **⬆️ Version check & self-update** — Automatic check for new releases with optional auto-update; disable with `GITKIT_NO_UPDATE_CHECK`. - **📦 Single binary** — No Node.js, no Python, no extra runtime. --- diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5ce2e76..c18130a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -39,10 +39,13 @@ Running `gitkit` with no command starts the interactive wizard. | `gitkit lock` | Block commits until `gitkit unlock` | | `gitkit lock --reason ` | Set the message shown on a blocked commit | | `gitkit lock --timeout ` | Auto-expire the lock, e.g. `30m`, `2h` | +| `gitkit lock --push` | Also block pushes (in addition to commits) | +| `gitkit lock --all` | Block both commits and pushes | | `gitkit lock status` | Show whether a lock is active, its reason and expiry | +| `gitkit lock status --json` | Show lock status as machine-readable JSON with exit code signal | | `gitkit unlock` | Remove the lock and restore any backed-up hook | -`git commit --no-verify` bypasses the lock — see [Lock](lock.md) for why +`git commit --no-verify` and `git push --no-verify` bypass the lock — see [Lock](lock.md) for why that is accepted rather than defended against. ## Ignore From 437aded3798f21e358252b7209df13b3e789a693 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 12 Aug 2026 08:33:48 -0500 Subject: [PATCH 08/20] docs(lock): Document push-blocking with --push and --all flags Add comprehensive documentation for push-blocking feature shipped in commit 6a48cc2. Includes: - Examples for --push and --all flags - Explanation of pre-push hook mechanics - Per-operation status output (commit/push locked separately) - Updated bypass documentation for git push --no-verify Previously, docs/lock.md only covered commit-blocking, leaving half the feature undocumented. --- docs/lock.md | 60 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/docs/lock.md b/docs/lock.md index 08e2631..3efbdea 100644 --- a/docs/lock.md +++ b/docs/lock.md @@ -11,7 +11,9 @@ committing to a repository, locally, for the duration of a session. ```bash gitkit lock # block commits until `gitkit unlock` -gitkit lock --reason "Agent session" # custom message shown on a blocked commit +gitkit lock --push # block pushes instead of commits +gitkit lock --all # block both commits and pushes +gitkit lock --reason "Agent session" # custom message shown on a blocked operation gitkit lock --timeout 30m # auto-expires after 30 minutes gitkit lock status # show whether a lock is active gitkit lock status --json # machine-readable status, see below @@ -19,29 +21,51 @@ gitkit unlock # remove the lock ``` Locking twice updates the existing lock (reason, timeout) instead of -stacking or erroring. +stacking or erroring. The `--push` and `--all` flags can be used to add +or modify which operations are locked without removing the existing lock. ## How it works `gitkit lock` writes a small JSON state file at `.git/gitkit.lock` and -installs a `pre-commit` hook that reads it. The hook is pure POSIX `sh` — -no dependency on the `gitkit` binary — so it stays fast on every commit. -A missing, empty, or malformed lock file is always treated as unlocked: -a corrupt lock never blocks a commit. +installs `pre-commit` and/or `pre-push` hooks that read it. The hooks are +pure POSIX `sh` — no dependency on the `gitkit` binary — so they stay fast +on every commit and push. A missing, empty, or malformed lock file is always +treated as unlocked: a corrupt lock never blocks an operation. -If you already had a `pre-commit` hook, it is backed up to -`pre-commit.gitkit-orig` and chained to — it still runs after the lock -check passes. `gitkit unlock` restores it and removes the backup. +By default, `gitkit lock` blocks commits only. Use `--push` to add push +blocking, or `--all` to block both. You can call lock multiple times to +add or change which operations are blocked. + +If you already had a `pre-commit` or `pre-push` hook, it is backed up to +`pre-commit.gitkit-orig` / `pre-push.gitkit-orig` and chained to — it +still runs after the lock check passes. `gitkit unlock` restores them and +removes the backups. The lock is per-repository, local only, and never committed or pushed — it lives entirely under `.git/`. +## Status output + +Both `gitkit lock status` (human-readable) and `gitkit lock status --json` +(machine-readable) show per-operation status. This lets you see at a glance +which operations are currently locked: + +``` +Locked: Agent session +Locked at: 2026-01-01T10:00:00Z +Expires at: 2026-01-01T10:30:00Z +Commit: locked +Push: not locked +``` + +This shows that commits are blocked, but pushes are allowed. + ## Machine-readable status: `lock status --json` `gitkit lock status --json` emits the same state the human-readable `gitkit lock status` shows, as a single line of JSON on stdout, so another program can check whether a repository is locked instead of discovering it -by having a commit rejected. This is a **read-only** surface: gitkit does +by having an operation rejected. This is a **read-only** surface: gitkit does not call out to, or know about, whatever consumes it. ```bash @@ -55,7 +79,7 @@ rely on a key being renamed or removed without a version bump): | Key | Type | Meaning | |---------------|-------------------|--------------------------------------------------------------------------| | `active` | `bool` | Whether the lock currently blocks the operations it lists — `false` if there is no lock, the lock file is malformed, `operations` is empty, or the lock has expired. | -| `operations` | `string[]` | The operations the lock covers (e.g. `"commit"`, `"push"`). Empty when there is no lock. | +| `operations` | `string[]` | The operations the lock covers. Can be `"commit"`, `"push"`, or both. Empty when there is no lock. | | `locked_at` | `string \| null` | RFC 3339 timestamp the lock was set, or `null` when there is no lock. | | `expires_at` | `string \| null` | RFC 3339 timestamp the lock expires, or `null` for a lock with no timeout (or no lock at all). | | `reason` | `string \| null` | The `--reason` text, or `null` when there is no lock. | @@ -103,11 +127,11 @@ Notes for a direct reader: - This file is local only, lives entirely under `.git/`, and is never committed or pushed. -## Limitation: `--no-verify` +## Limitations: `--no-verify` bypass -`git commit --no-verify` bypasses all pre-commit hooks, including this -one. **This is expected and not treated as a bug.** The lock's threat -model is an AI agent following its instructions, not a human deliberately -working around a local safeguard — so no attempt is made to defend -against `--no-verify`. If you need a guarantee that survives a -determined bypass, this is not that guarantee. +Both `git commit --no-verify` and `git push --no-verify` bypass their +respective hooks, including the lock checks. **This is expected and not +treated as a bug.** The lock's threat model is an AI agent following its +instructions, not a human deliberately working around a local safeguard — +so no attempt is made to defend against `--no-verify`. If you need a +guarantee that survives a determined bypass, this is not that guarantee. From ae55ec7e802bf827b1bb223d322248bcd76559ce Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 12 Aug 2026 08:33:58 -0500 Subject: [PATCH 09/20] docs: Add self-update documentation and update feature summary Document the automatic version check and self-update mechanism shipped in commit 6a48cc2. Includes: - Self-update section in Installation guide - GITKIT_NO_UPDATE_CHECK environment variable to opt out - Cargo-managed install handling (points to cargo install --force) - Binary-in-place update behavior Also update index.md to reflect current capabilities: - Agent lock now blocks commits and/or pushes (not just commits) - Add self-update to the feature list - Update documentation guide to mention automatic self-updates --- docs/index.md | 9 +++++---- docs/installation.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 56f5209..bcb53f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,20 +28,21 @@ project with one command. into the wizard. - **Hook management** — built-in hooks (conventional commits, secret detection, branch naming) or your own shell command. -- **Agent lock** — block commits locally and reversibly for the - duration of an agent session with `gitkit lock`. +- **Agent lock** — block commits and/or pushes locally and reversibly for + the duration of an agent session with `gitkit lock`. - **Ignore & attribute presets** — all gitignore.io templates plus built-ins, line-ending and binary presets. - **Curated git config** — practical presets with `--global`/`--local` scope and idempotency detection. - **Builds** — save a configuration once, apply it everywhere. +- **Self-update** — gitkit checks GitHub for newer releases and updates itself automatically. ## How the documentation is organized -- [Installation](installation.md) — install, update and uninstall. +- [Installation](installation.md) — install, update (including automatic self-updates), and uninstall. - [Quick Start](quickstart.md) — the wizard and the one-liner workflow. - [Hooks](hooks.md) — built-in and custom hooks. -- [Lock](lock.md) — block commits for an agent session, and its limits. +- [Lock](lock.md) — block commits and/or pushes for an agent session, and its limits. - [Ignore & Attributes](ignore-and-attributes.md) — `.gitignore` and `.gitattributes`. - [Config Presets](config-presets.md) — curated git config, scopes, idempotency. - [Builds](builds.md) — save and reuse configurations. diff --git a/docs/installation.md b/docs/installation.md index 505bc4c..b591027 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -34,6 +34,35 @@ Precompiled binaries for Linux x86_64, macOS x86_64/ARM64 and Windows x86_64 are published on the [Releases](https://github.com/UniverLab/gitkit/releases) page. +## Self-update + +gitkit automatically checks GitHub for newer releases each time it runs and +offers to update if a newer version is available. The update replaces the +running binary in place — no need to reinstall or restart your shell between +commands. + +### Disable update checks + +If you prefer to manage updates yourself, disable the check with: + +```bash +export GITKIT_NO_UPDATE_CHECK=1 +``` + +Add this to your shell profile to make it permanent. + +### Cargo-installed versions + +If gitkit was installed with `cargo install gitkit`, the auto-updater will +detect this and ask you to update using cargo instead: + +```bash +cargo install --force gitkit +``` + +This is because cargo manages the installation and needs to be involved in +the update to maintain consistency. + ## Uninstall **Linux / macOS:** From 1fc060efd33d39126c33b71e36e216badf3ac544 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 12 Aug 2026 09:52:10 -0500 Subject: [PATCH 10/20] feat(hooks): add no-trailers builtin to reject AI attribution trailers --- README.md | 1 + docs/hooks.md | 12 ++- src/hooks/builtins.rs | 202 ++++++++++++++++++++++++++++++++++++++++++ src/hooks/mod.rs | 1 + 4 files changed, 215 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 110db3b..a224115 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,7 @@ Run `gitkit hooks list --available` to see these without leaving the terminal. | Name | Hook | Description | |---|---|---| | `conventional-commits` | `commit-msg` | Validates Conventional Commits format | +| `no-trailers` | `commit-msg` | Rejects commit messages carrying AI attribution trailers | | `no-secrets` | `pre-commit` | Detects common secret patterns in staged changes | | `branch-naming` | `pre-commit` | Validates branch name matches convention | diff --git a/docs/hooks.md b/docs/hooks.md index 19ea89e..6838a6c 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -1,6 +1,6 @@ --- title: Hooks -description: Built-in hooks (conventional commits, secret detection, branch naming) and custom shell commands. +description: Built-in hooks (conventional commits, AI trailer rejection, secret detection, branch naming) and custom shell commands. order: 4 --- @@ -13,6 +13,7 @@ Built-ins are embedded in the binary — no network required. | Name | Hook | Description | |---|---|---| | `conventional-commits` | `commit-msg` | Validates Conventional Commits format | +| `no-trailers` | `commit-msg` | Rejects commit messages carrying AI attribution trailers | | `no-secrets` | `pre-commit` | Detects common secret patterns in staged changes | | `branch-naming` | `pre-commit` | Validates branch name matches convention | @@ -21,6 +22,15 @@ gitkit hooks list --available # see all built-ins with descriptions gitkit hooks add no-secrets # install one (hook type inferred) ``` +### `no-trailers` + +Rejects a commit whose message contains a `Co-Authored-By:`, `Assisted-By:` +or `AI-Assisted-By:` line naming a known AI vendor no-reply address (at +minimum `noreply@anthropic.com`), a `Claude-Session:` line, or a "Generated +with" line. Genuine human `Co-Authored-By:` trailers are left untouched — a +rule in a prompt is advisory, this hook is not. The commit is refused with +the offending line and its line number; it never rewrites your message. + ## Custom hooks Wire any shell command into a git hook: diff --git a/src/hooks/builtins.rs b/src/hooks/builtins.rs index a845728..a5d5f1b 100644 --- a/src/hooks/builtins.rs +++ b/src/hooks/builtins.rs @@ -12,6 +12,12 @@ pub(crate) const ALL: &[Builtin] = &[ description: "Validates Conventional Commits format", script: CONVENTIONAL_COMMITS, }, + Builtin { + name: "no-trailers", + hook: "commit-msg", + description: "Rejects commit messages carrying AI attribution trailers", + script: NO_TRAILERS, + }, Builtin { name: "no-secrets", hook: "pre-commit", @@ -41,6 +47,34 @@ if ! echo "$commit_msg" | grep -qE "$pattern"; then fi "#; +/// Known AI vendor no-reply addresses that mark an autogenerated attribution +/// trailer (e.g. `Co-Authored-By: Claude Opus 5 `). +/// Add a vendor here, and keep the `vendor_addresses` pattern inside +/// `NO_TRAILERS` in sync — `no_trailers_script_matches_every_known_vendor` +/// in the tests below enforces that. Only read by tests, hence the allow. +#[allow(dead_code)] +pub(crate) const AI_VENDOR_NOREPLY_ADDRESSES: &[&str] = &["noreply@anthropic.com"]; + +const NO_TRAILERS: &str = r#"#!/bin/sh +# Rejects commit messages carrying AI attribution trailers: a Co-Authored-By, +# Assisted-By or AI-Assisted-By line naming a known AI vendor no-reply +# address, a Claude-Session line, or a "Generated with" line. Genuine human +# Co-Authored-By trailers are left alone. +msg_file="$1" +vendor_addresses='noreply@anthropic\.com' +pattern="^(Co-Authored-By|Assisted-By|AI-Assisted-By):.*(${vendor_addresses})|^Claude-Session:|Generated with" + +matches=$(grep -niE "$pattern" "$msg_file") +if [ -n "$matches" ]; then + echo "ERROR: commit message contains an AI attribution trailer:" + echo "$matches" | sed 's/^/ /' + echo "" + echo "Remove the line(s) above and commit again." + echo "Human co-authorship is fine: only known AI vendor no-reply addresses (e.g. noreply@anthropic.com) are rejected." + exit 1 +fi +"#; + const NO_SECRETS: &str = r#"#!/bin/sh # Detects common secret patterns. Not exhaustive — use dedicated tools for production. patterns='(AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|ghp_[0-9A-Za-z]{36}|sk-[0-9A-Za-z]{48}|password\s*=\s*["'"'"'][^"'"'"']{8,})' @@ -60,3 +94,171 @@ if ! echo "$branch" | grep -qE "$pattern"; then exit 1 fi "#; + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::process::Command; + + /// Runs the `no-trailers` script exactly as git's commit-msg hook would: + /// `sh