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 @@
+
+ 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