Back to blog
EngineeringDate unavailable· min read

Git Hooks That Travel: The Road to Portable Development Automation

I spent three weeks this month fixing git hooks that I thought were working fine. They worked perfectly on my machine. They broke everywhere else. This is the story of how we taught our development automation to travel.

The Absolute Path Problem

It started with commit 7e0e794. Our session-branch git hook had a hardcoded absolute path: /Users/ryan/strug-standards. Every time the hook ran, it reached out to that exact location to find configuration files and templates. On my MacBook Pro, this worked beautifully. On the CI runner? Silent failure. On a teammate's machine? Error message about a missing directory.

The fix seems obvious in retrospect: resolve the repository root at runtime. Instead of assuming a fixed path, walk up from the current directory until you find the .git folder. But the lesson is deeper than the technical solution.

When you're the only person running code, you don't feel portability problems. The code runs in exactly one environment: yours. Every assumption you bake in—about filesystem layout, about working directory, about installed tools—stays invisible. It's only when the code travels that those assumptions break.

The Settings File You Should Never Commit

The next problem appeared in our pre-commit hooks. We use .claude/settings.local.json files to configure local development preferences—things like model selection, temperature settings, and MCP server endpoints. These files contain machine-specific paths and personal API preferences. They should never reach version control.

But gitignore patterns are tricky. A blanket .claude/ ignore hides everything, including .claude/settings.json (the shared configuration we do want in version control). A narrow pattern misses variations. So we built enforcement into the commit workflow itself.

Commit e877155 added a Husky hook that scans the staged files before every commit. If it finds a settings.local.json file, it rejects the commit with a clear error message. This is enforcement as documentation—the hook tells you what you did wrong and why it matters.

Fail Closed: When Safety Guards Can't See

The settings guard worked. Until it didn't. We discovered a subtle failure mode: if git diff --cached couldn't read the index (corrupted state, mid-rebase, unusual repository structure), the pipeline would silently succeed. The guard would see zero files and wave everything through.

This is the difference between failing open and failing closed. Failing open means "if I can't verify safety, proceed anyway." Failing closed means "if I can't verify safety, stop."

Commits 18c5901 and ee3ec72 added set -o pipefail to our pre-commit hooks. Now if any command in a pipeline fails, the entire hook fails. If git diff can't read the index, the commit stops. The guard no longer has a blind spot.

This philosophy extends beyond git hooks. Any automated safety mechanism should fail closed. If your CI pipeline can't run tests, it shouldn't deploy. If your authentication check can't reach the identity provider, it shouldn't grant access. Uncertainty should default to safety.

The .gitignore Rule That Hid Our Quality Gates

The gitignore problem surfaced again in a different form. Commit e825fcc fixed an overly broad .claude/ ignore pattern that was hiding .claude/settings.json—our PreToolUse MCP server configuration.

The PreToolUse server acts as a quality gate for AI agent actions. It intercepts tool calls before execution and applies business logic checks: Does this file modification align with the mission? Is this API call appropriate for the current context? Without the configuration in version control, new environments couldn't see these safeguards. The quality gates existed in my local setup but were invisible to everyone else.

The fix narrowed the ignore rule to exclude only settings.local.json files while allowing the shared configuration through. Now the quality gates travel with the code.

What We Learned About Automation Portability

Running a one-person autonomous engineering organization creates an unusual development pattern. Code often runs in exactly one environment before it ships. You don't have a coworker who clones your branch on a different machine and discovers that it assumes macOS-specific paths. You don't have a teammate who hits the edge case where git state is ambiguous and your safety check fails open.

These four commits taught me to think about portability differently:

Runtime resolution over hardcoded paths. Every script should discover its environment at runtime. Repository roots, config file locations, working directories—none of these should be baked in. The code should work regardless of where it's cloned.

Enforcement as documentation. Pre-commit hooks that block bad commits explain why something is wrong in the same moment they prevent it. This is better than documentation that lives in a README somewhere. The enforcement is the documentation.

Fail closed by default. Safety mechanisms should never silently succeed when they can't verify safety. Uncertainty means stop, not proceed. set -o pipefail is now mandatory in every shell script that guards anything important.

Explicit gitignore patterns. Wildcard patterns are convenient but dangerous. They hide both what you want hidden and what you need visible. When in doubt, enumerate. When configuration matters, version control it.

What's Next

We're applying these portability principles to our CI/CD pipeline next. The GitHub Actions workflows currently assume certain environment variables exist and certain tools are installed. We're building explicit environment verification steps that fail fast with clear error messages when assumptions don't hold.

We're also expanding the pre-commit guard pattern beyond settings.local.json. There are other file categories that should never reach version control: API keys, local database credentials, debug logs, temporary experiment outputs. Each will get its own enforcement rule with its own explanation.

The deeper pattern is this: as our autonomous engineering team grows, the infrastructure needs to encode more institutional knowledge. Pre-commit hooks that prevent mistakes. Runtime checks that discover their environment. Safety mechanisms that fail closed. The code should carry its own quality gates, its own documentation, and its own portability.

Because eventually, this code will travel. To CI runners. To production servers. To other developers' machines. And when it does, it should work the same way it worked on mine.