From 6b6c03124203fc81ed31d87c273933c73a8515a1 Mon Sep 17 00:00:00 2001 From: mace Date: Thu, 17 Sep 2026 16:07:48 +0200 Subject: [PATCH] Restore connections.env without triggering post-checkout hooks git checkout fires post-checkout even for a single-file checkout (flag=0), which was tripping an rsync hook in the target repo. Read the committed blob with git cat-file instead and write it directly - plumbing, no worktree update, no hooks - with a test that plants a post-checkout hook and asserts it never runs. --- README.md | 2 +- src/git/command.rs | 63 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e15c62c..cb05d7a 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ dev_tools foo down ## What it does 1. Starts or stops the Docker Compose service defined by `DOCKER_SERVICE` / `DOCKER_DIR`. -2. `conf/global/connections.env`: `up` toggles it to the CI credentials and marks it `git update-index --assume-unchanged` to avoid accidental commits; `down` unmarks it and restores it to its committed state with `git checkout` (rather than computing the reverse toggle), so it's guaranteed byte-for-byte identical to HEAD. +2. `conf/global/connections.env`: `up` toggles it to the CI credentials and marks it `git update-index --assume-unchanged` to avoid accidental commits; `down` unmarks it and restores it to its committed state by reading the committed blob with `git cat-file` (rather than computing the reverse toggle or running `git checkout`, which would fire the repo's `post-checkout` hook), so it's guaranteed byte-for-byte identical to HEAD. 3. Toggles database connection entries in `/conf/local/.env`: `up` switches the connection to `ci`, `down` switches it to `staging`. ## Tests diff --git a/src/git/command.rs b/src/git/command.rs index d797e3d..8900e98 100644 --- a/src/git/command.rs +++ b/src/git/command.rs @@ -1,6 +1,7 @@ +use std::fs; use std::process::Command; -use anyhow::{ensure, Result}; +use anyhow::{ensure, Context, Result}; use crate::{config::parse::GLOBAL_CONNECTION_PATH, env::config::DevToolsConf}; @@ -36,20 +37,31 @@ pub fn toggle_index(start: bool, config: &DevToolsConf) -> Result<()> { ) } -/// Restores `connections.env` to its committed state via `git checkout`, -/// used on `down` instead of computing the reverse toggle so the file ends -/// up byte-for-byte identical to HEAD, with no risk of a parsing mismatch. +/// Restores `connections.env` to its committed state, used on `down` instead +/// of computing the reverse toggle so the file ends up byte-for-byte +/// identical to HEAD, with no risk of a parsing mismatch. Reads the +/// committed blob with `git cat-file` and writes it directly rather than +/// running `git checkout`, since `checkout` fires `post-checkout` even for a +/// single-file checkout and `cat-file` (plumbing, no worktree update) never +/// triggers hooks. pub fn restore_connections_env(config: &DevToolsConf) -> Result<()> { - let project_path = format!( - "{}{}", - config.project_dir_as_string(), - GLOBAL_CONNECTION_PATH + let project_dir = config.project_dir_as_string(); + let project_path = format!("{project_dir}{GLOBAL_CONNECTION_PATH}"); + + let output = Command::new("git") + .current_dir(&project_dir) + .args(["cat-file", "-p", &format!("HEAD:{GLOBAL_CONNECTION_PATH}")]) + .output()?; + + ensure!( + output.status.success(), + "git cat-file -p HEAD:{GLOBAL_CONNECTION_PATH} failed with {}", + output.status ); - run_git( - &config.project_dir_as_string(), - &["checkout", "--", &project_path], - ) + fs::write(&project_path, output.stdout).context("Cannot write connections.env")?; + + Ok(()) } #[cfg(test)] @@ -141,6 +153,33 @@ mod tests { ); } + #[test] + fn restore_connections_env_does_not_trigger_post_checkout_hook() { + let repo = TempGitRepo::new("no_hook"); + repo.commit_connections_env("ci.db.user=\"m.rothenhaeusler\"\n"); + fs::write(repo.join(GLOBAL_CONNECTION_PATH), "corrupted content\n").unwrap(); + + let marker = repo.join("hook-ran"); + let hooks_dir = repo.join(".git/hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let hook_path = hooks_dir.join("post-checkout"); + fs::write( + &hook_path, + format!("#!/bin/sh\ntouch {}\n", marker.display()), + ) + .unwrap(); + let mut perms = fs::metadata(&hook_path).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + fs::set_permissions(&hook_path, perms).unwrap(); + + restore_connections_env(&config_for(&repo)).unwrap(); + + assert!( + !marker.exists(), + "restore_connections_env must not trigger post-checkout hooks" + ); + } + #[test] fn restore_connections_env_fails_when_not_a_git_repo() { let dir = std::env::temp_dir().join(format!(