All Articles Claude Code

Git Safety Hook: Preventing Dangerous Git Operations

We've all been there. Your fingers twitch toward git push --force, and for a split second, you wonder if that's about to blow up your team's work.

Stop Bad Commits Before They Ship

We’ve all been there. Your fingers twitch toward git push --force, and for a split second, you wonder if that’s about to blow up your team’s work. Or worse—you realize too late that you committed sensitive credentials, a 500MB binary file, or directly to main during a caffeine haze.

Git safety hooks sit between your intention and disaster. They’re automated guardrails that catch dangerous operations before they happen, without being annoying about it. Let’s build a complete safety suite that protects your repository while staying flexible enough for legitimate use cases.

The Hidden Costs of Git Mistakes: A Deeper Look

When you think about git mistakes, you probably think about them as isolated incidents. Someone force-pushes to main, chaos ensues for a few hours, and then life goes on. But that surface-level view misses the real cost. Git disasters cascade in ways most teams don’t anticipate until they’ve experienced one.

Consider what happens during a force push to main. It’s not just that commits disappear. When you rewrite history with a force push, every developer with a local clone of that repository becomes desynchronized. Their git history no longer matches the remote. They can’t push their changes easily. They get cryptic merge conflicts that aren’t real conflicts—they’re artifacts of history mismatch. A thirty-second mistake creates a problem that takes hours for a team of five to untangle. If it happens during a critical period (late afternoon before a release, for instance), that time pressure causes more mistakes. Someone applies a quick fix that’s incorrect. Someone doesn’t test properly. Additional commits are reverted that shouldn’t have been. The single mistake snowballs.

The credential leak scenario is even worse because the damage extends beyond your team. An exposed database password isn’t just about your database access being compromised—it’s about the attackers potentially accessing customer data, exfiltrating intellectual property, or using your infrastructure for attacks against other organizations. The financial impact of a breach investigation, customer notification, potentially regulatory fines, and credit monitoring services can be substantial. A password that leaked for even a few hours represents risk that persists. You have to rotate all credentials downstream from that password, verify nothing was accessed, and often you can’t be completely certain if you don’t have comprehensive logging.

Binary files bloating your repository are particularly insidious because the problem is permanent and only gets worse over time. A developer accidentally commits a compiled binary today. The repository is now 50MB heavier. Next month, someone else commits another binary. Now it’s 150MB. The repository accumulates these files over years. Cloning becomes slower. CI/CD builds take longer. You can’t easily remove binaries from git history without rewriting the entire history, which is a painful, team-coordinating operation. Teams often resign themselves to slow clones and slow builds because the cost of fixing it seems too high.

Direct commits to main undermine your entire development process. If your CI/CD deploys main automatically, you’ve potentially deployed untested code. If you use semantic versioning and automated changelog generation, direct commits break that automation—your version numbers no longer accurately reflect what changed. If you use branch protection rules on GitHub or similar platforms, direct commits bypass those rules entirely (because they happen locally before pushing). You’ve subverted your team’s process to save a few seconds of creating a branch. The cost is far higher than the savings.

Understanding the Real Costs of Git Mistakes

Most teams don’t understand how expensive git disasters really are until they experience one. A force push to main isn’t just a “oops” moment. It’s a full incident. Someone has to stop what they’re doing, figure out what was pushed, check if it reached production, potentially coordinate a rollback, notify stakeholders if the wrong code shipped. If nobody noticed immediately, it might have been deployed already. Now you have to investigate what changed, who deployed it, and whether it caused any damage. A thirty-second mistake becomes a three-hour incident that involves multiple people. If production was affected, it becomes a six-hour incident. If customer data was involved, it becomes a twelve-hour incident with compliance and legal involvement.

Credentials committed to git are even worse. The moment credentials hit git history, they’re effectively compromised. Even if you delete the file and force push, those credentials are in git history. Anyone with repository access has seen them. You have to rotate those credentials immediately. If they’re database passwords, you have to update all applications that use them. If they’re API keys, you have to revoke them. If they’re SSH keys, you have to generate new ones. What seemed like a simple mistake cascades into infrastructure changes. Multiply this by one incident per six months across a team of ten people, and that’s ten incidents per year, thirty hours per incident, three hundred hours annually spent on credential disasters. That’s more than a full-time engineer’s salary.

Binary files bloat repositories permanently. A developer accidentally commits a 200MB compiled binary. That binary is now in git history forever. Future developers clone the repository and pull down that 200MB. It doesn’t matter if you delete the file and commit an empty directory. Git still stores it in history. Every clone gets slower. Your CI/CD builds take longer because cloning takes longer. Over years, a repository accumulates dozens of binaries and becomes unwieldy. The solution requires a painful recovery process involving git filter-branch or other history rewriting tools.

Git safety hooks prevent all of this with no friction when used correctly and minimal friction when overridden. They’re the single highest-impact safety tool a team can implement.

The Problem With Manual Discipline

Relying on developer memory for “don’t do this” is fragile. Teams grow. Context switches happen. A new dev on the team might not know your conventions yet. Even experienced developers make mistakes under pressure.

A well-designed safety hook system achieves several things at once. It prevents force pushes to protected branches—probably the most common cause of team chaos. It blocks direct commits to main/master, which should only receive code through pull requests. It enforces branch naming conventions so your git history is readable and organized. It warns on risky additions like binaries or large files that shouldn’t be in the repository. It stops accidental commits of secrets or credentials before they’re ever stored in git history.

Most importantly, it remains configurable so legitimate operations still work. You’re not creating a system that frustrates developers; you’re creating a system that prevents costly mistakes while letting people work naturally.

The key is making safety automatic while keeping it intelligent enough not to frustrate developers. When a hook blocks something, developers should understand why it was blocked and how to do it safely. That builds a culture where safety is collaborative, not adversarial.

Why This Matters: Real Costs of Git Mistakes

Think about the true cost of a single git disaster. When someone force-pushes to main, they’re not just rewriting history. They’re potentially destroying days of work from other developers. They’re breaking everyone’s local branches. They’re creating confusion about what’s actually deployed. Recovery takes hours of careful coordination, and there’s a window where the actual merged code might differ from what’s in production.

A developer who accidentally commits .env files containing API keys or database credentials doesn’t just create a bad commit. Those credentials are now permanently in git history, even if you delete the file. You have to rotate every credential that was exposed. You have to notify security teams. You have to investigate if anyone accessed the repository between the commit and discovery. The blast radius is enormous.

Large binaries committed by accident create repository bloat that never really goes away. Your clone times get slower. Your CI/CD pipelines get slower. Developers on slow connections start having problems. Over months and years, this compounds. Git garbage collection can help, but binaries are sticky—they linger in history.

Direct commits to main break your release process. If your CI/CD deploys main automatically, you’ve potentially shipped untested code. If you use semantic versioning or changelog generation, direct commits break that automation. You lose the ability to track what changed between releases.

A solid hook system prevents all of this. It’s not just about preventing human error in the moment—it’s about preventing the cascading downstream costs of those errors. A 50-millisecond hook that blocks a dangerous operation saves hours of incident response.

Architecture: A Layered Safety System

Your git safety system should work at multiple levels to provide defense in depth:

  1. Pre-commit hook: Inspects staged changes before they become a commit
  2. Commit-msg hook: Validates commit message format and content
  3. Pre-push hook: Catches dangerous push operations before they reach the remote
  4. Update hook: Server-side protection (last line of defense)

We’ll focus on the pre-commit and pre-push hooks, which are where most safety wins happen. These hooks run locally on developers’ machines, providing immediate feedback without requiring infrastructure changes.

The beauty of local hooks is they’re fast—feedback happens in milliseconds. Developers see the problem immediately and can fix it without waiting for CI/CD or server-side checks. This matters for adoption; if hooks are slow, developers learn to bypass them. If they’re fast and helpful, developers respect them.

Why Pre-Commit Matters

The pre-commit phase is special. Your working directory might have all sorts of experimental code, temporary files, and WIP changes. But the staging area contains only the changes you explicitly said you wanted to commit. This distinction is crucial. We’re not rejecting all your work—just the specific changes you staged for commit. Developers maintain complete control.

Before a commit is created, we can check for secrets in staged files. We can verify file sizes. We can check for binary files that shouldn’t be committed. We can run formatting checks. Any of these could block the commit, but developers can unstage the problematic files, fix them, and re-stage. The workflow remains natural and non-disruptive.

Why Pre-Push Matters

The pre-push phase is your last chance before code reaches the remote repository. By this point, commits are already created locally. But they haven’t been shared yet. If we catch problems here, you can rewrite history locally without affecting anyone else. This is where we stop force-pushes to protected branches, direct commits to main, and other repository-damaging operations.

The pre-push hook has information about what you’re trying to push to which branch on which remote. This lets us make intelligent decisions. A force-push to your personal feature branch might be fine. A force-push to main is absolutely not fine. We can distinguish between them.

Building the Pre-Commit Safety Hook

Your pre-commit hook runs after git add but before git commit. It’s the perfect place to catch secrets, large files, and formatting issues. The hook has access to the staging area, which is crucial. You’re not rejecting the entire working directory—just the changes someone explicitly staged. This gives developers fine-grained control.

// .git/hooks/pre-commit (or in Node-based hook system)




const CONFIG = {
  maxFileSize: 5 * 1024 * 1024, // 5MB
  maxTotalSize: 50 * 1024 * 1024, // 50MB per commit
  blockedPatterns: [
    /\.env(\.\w+)?$/,
    /secrets?\.json$/,
    /credentials/i,
    /\.pem$/,
    /\.key$/,
    /aws_access_key/i,
    /PRIVATE[\s_]*KEY/i,
  ],
  binaryExtensions: [".exe", ".bin", ".o", ".so", ".dylib", ".dll"],
  allowedBinaryExtensions: [".png", ".jpg", ".pdf", ".woff2"],
};

function checkForSecrets() {
  try {
    const staged = execSync("git diff --cached --name-only")
      .toString()
      .split("\n")
      .filter(Boolean);
    const issues = [];

    for (const file of staged) {
      const filename = path.basename(file);
      for (const pattern of CONFIG.blockedPatterns) {
        if (pattern.test(filename)) {
          issues.push(`🚫 Secret risk: ${file} matches dangerous pattern`);
        }
      }
    }

    if (issues.length > 0) {
      console.error("\n⚠️  COMMIT BLOCKED: Potential secrets detected\n");
      issues.forEach((issue) => console.error(issue));
      console.error(
        "\nTo override: git commit --no-verify (not recommended)\n",
      );
      process.exit(1);
    }
  } catch (error) {
    console.error("Secret check failed:", error.message);
  }
}

function checkFileSizes() {
  try {
    const staged = execSync("git diff --cached --name-only")
      .toString()
      .split("\n")
      .filter(Boolean);
    let totalSize = 0;
    const largeFiles = [];

    for (const file of staged) {
      if (!fs.existsSync(file)) continue;

      const stat = fs.statSync(file);
      const size = stat.size;
      totalSize += size;

      if (size > CONFIG.maxFileSize) {
        const mb = (size / (1024 * 1024)).toFixed(2);
        largeFiles.push({ file, size: mb });
      }
    }

    if (largeFiles.length > 0) {
      console.error("\n⚠️  COMMIT BLOCKED: Large files detected\n");
      largeFiles.forEach(({ file, size }) => {
        console.error(
          `  ${file}: ${size}MB (max: ${CONFIG.maxFileSize / (1024 * 1024)}MB)`,
        );
      });
      console.error("\nConsider using Git LFS for binary assets.\n");
      process.exit(1);
    }

    if (totalSize > CONFIG.maxTotalSize) {
      const mb = (totalSize / (1024 * 1024)).toFixed(2);
      console.error(`\n⚠️  COMMIT BLOCKED: Total size ${mb}MB exceeds limit\n`);
      process.exit(1);
    }
  } catch (error) {
    console.error("Size check failed:", error.message);
  }
}

function checkBinaryFiles() {
  try {
    const staged = execSync("git diff --cached --name-only")
      .toString()
      .split("\n")
      .filter(Boolean);
    const suspiciousBinaries = [];

    for (const file of staged) {
      const ext = path.extname(file).toLowerCase();

      if (
        CONFIG.binaryExtensions.includes(ext) &&
        !CONFIG.allowedBinaryExtensions.includes(ext)
      ) {
        suspiciousBinaries.push(file);
      }
    }

    if (suspiciousBinaries.length > 0) {
      console.warn("\n⚠️  WARNING: Binary files detected (advisory)\n");
      suspiciousBinaries.forEach((f) => console.warn(`  ${f}`));
      console.warn("\nBinaries should usually be built, not committed.\n");
      // Don't exit—this is a warning, not a block
    }
  } catch (error) {
    console.error("Binary check failed:", error.message);
  }
}

// Run all checks
checkForSecrets();
checkFileSizes();
checkBinaryFiles();

console.log("✅ Pre-commit checks passed\n");
process.exit(0);

This hook catches the most common mistakes: accidentally committing .env files, adding gigantic binaries, or staging credentials. The key insight is that we check staged changes, not your working directory. This gives developers fine-grained control—they can stage the good stuff and exclude the bad stuff.

How Secret Detection Works at Scale

The secret detection in the pre-commit hook uses filename patterns. We check if staged files match patterns like .env, secrets.json, credentials, .pem, .key, or contain “aws_access_key” or “PRIVATE KEY”. This catches 90% of accidental credential commits without requiring entropy analysis or content scanning.

The patterns are conservative by design. We’re trying to prevent the most obvious mistakes: people staging .env files directly, or files named secrets.json, or PEM-encoded private keys. We’re not trying to catch encoded credentials or sneaky attacks. Those are rare enough that manual vigilance handles them.

When the hook blocks a file, the developer sees the filename immediately and understands why. They can unstage it, add it to .gitignore, and move on. The workflow is non-disruptive.

File Size Constraints

File size limits prevent two problems. First, large binaries bloat the repository. A 100MB binary committed to git means every clone includes that 100MB. Your CI/CD times slow down. Everyone’s local clone gets slower. This compounds over time.

Second, git history includes every version of every file. If you commit a 100MB file, then change it to 90MB, then change it again, git stores all three versions. Your repository size explodes. Even if you delete the file later, the history remains.

The hook checks two things. First, individual file size. If any file exceeds the limit, we block it and suggest Git LFS (Git Large File Storage), which is designed for exactly this problem. Second, total commit size. Even if no individual file is huge, committing lots of moderately-large files at once should raise a warning.

These limits are configurable. A media company might set higher limits. A library with strict size constraints might set lower ones. The key is having them at all—many teams don’t, and regret it years later.

Building the Pre-Push Safety Hook

The pre-push hook runs after you type git push but before the push actually reaches the server. This is where you stop force-pushes and direct commits to protected branches. It’s your last opportunity to prevent team chaos. The hook runs on the local machine but has information about what you’re trying to push to remote, so it can make smart decisions about whether the operation is safe.

// .git/hooks/pre-push (Node implementation)


const PROTECTED_BRANCHES = ["main", "master", "production", "staging"];
const MAX_COMMIT_SIZE = 100 * 1024 * 1024; // 100MB per commit

function getBranchInfo() {
  try {
    const refspec = process.argv[3]; // From git pre-push args
    const localRef = process.argv[4];
    const remoteRef = process.argv[5];
    const remoteUrl = process.argv[1];

    return { refspec, localRef, remoteRef, remoteUrl };
  } catch {
    // Fallback: get current branch
    const branch = execSync("git rev-parse --abbrev-ref HEAD")
      .toString()
      .trim();
    return { branch };
  }
}

function checkForceFlag() {
  const args = process.argv.slice(2).join(" ");
  const isForce =
    args.includes("--force") ||
    args.includes("-f") ||
    args.includes("--force-with-lease");

  if (isForce) {
    const branch = execSync("git rev-parse --abbrev-ref HEAD")
      .toString()
      .trim();

    if (PROTECTED_BRANCHES.includes(branch)) {
      console.error("\n🚫 PUSH BLOCKED: Force push to protected branch\n");
      console.error(`Branch: ${branch}`);
      console.error("Protected branches cannot accept force pushes.\n");
      console.error("If you need to rewrite history:\n");
      console.error("1. Create a new feature branch\n");
      console.error("2. Force push there for experimentation\n");
      console.error("3. Create a clean PR when ready\n");
      process.exit(1);
    }
  }
}

function checkDirectCommitToProtected() {
  try {
    const branch = execSync("git rev-parse --abbrev-ref HEAD")
      .toString()
      .trim();

    if (!PROTECTED_BRANCHES.includes(branch)) {
      return; // Safe to push
    }

    const unpushedCommits = execSync(
      `git log origin/${branch}..HEAD --oneline`,
    ).toString();

    if (unpushedCommits.trim()) {
      const commitCount = unpushedCommits.split("\n").filter(Boolean).length;
      console.error("\n🚫 PUSH BLOCKED: Direct commits to protected branch\n");
      console.error(`You have ${commitCount} unpushed commit(s) on ${branch}`);
      console.error(
        "\nProtected branches should only receive commits via pull requests.\n",
      );
      console.error("Recommended workflow:\n");
      console.error("1. git checkout -b feature/your-feature\n");
      console.error("2. Make commits on your feature branch\n");
      console.error("3. Create a PR and get review\n");
      console.error("4. Merge via GitHub/GitLab\n");
      process.exit(1);
    }
  } catch (error) {
    // Branch tracking might not be set up; allow the push
    if (!error.message.includes("No such file or directory")) {
      console.warn("⚠️ Could not verify branch protection:", error.message);
    }
  }
}

function enforceBranchNaming() {
  const branch = execSync("git rev-parse --abbrev-ref HEAD").toString().trim();
  const validPatterns = [
    /^(feature|feat)\/[\w\-]+$/, // feature/user-auth
    /^(bugfix|fix|bug)\/[\w\-]+$/, // fix/login-timeout
    /^(hotfix)\/[\w\-]+$/, // hotfix/critical-bug
    /^(chore)\/[\w\-]+$/, // chore/update-deps
    /^(release)\/[\w\-\.]+$/, // release/v1.2.0
    /^(docs)\/[\w\-]+$/, // docs/api-reference
    /^(refactor)\/[\w\-]+$/, // refactor/auth-module
    /^main$|^master$|^develop$/, // Main branches
  ];

  const isValid = validPatterns.some((pattern) => pattern.test(branch));

  if (!isValid) {
    console.warn("\n⚠️  WARNING: Branch name does not follow convention\n");
    console.warn(`Branch: ${branch}\n`);
    console.warn("Expected format: [type]/[description]\n");
    console.warn("Examples:\n");
    console.warn("  feature/user-authentication\n");
    console.warn("  fix/login-bug\n");
    console.warn("  docs/api-guide\n");
    console.warn("\nContinuing anyway (not blocked)...\n");
    // Warning only; let push continue
  }
}

function checkCommitSize() {
  try {
    const branch = execSync("git rev-parse --abbrev-ref HEAD")
      .toString()
      .trim();
    const commits = execSync(`git log origin/${branch}..HEAD --format=%H`)
      .toString()
      .split("\n")
      .filter(Boolean);

    for (const commit of commits) {
      const size = parseInt(
        execSync(`git cat-file -s ${commit}`).toString().trim(),
      );

      if (size > MAX_COMMIT_SIZE) {
        const mb = (size / (1024 * 1024)).toFixed(2);
        console.warn(
          `\n⚠️  Large commit detected: ${commit.slice(0, 7)} (${mb}MB)\n`,
        );
      }
    }
  } catch (error) {
    // Not all git setups have this; just warn
    console.warn("⚠️ Could not check commit sizes");
  }
}

// Run all checks
checkForceFlag();
checkDirectCommitToProtected();
enforceBranchNaming();
checkCommitSize();

console.log("✅ Pre-push checks passed\n");
process.exit(0);

This hook prevents the most catastrophic mistakes: force pushing to main, committing directly to production branches, and naming branches that don’t follow your team’s conventions. It represents a philosophy: guide developers toward good practices while allowing legitimate use cases.

Why Branch Naming Matters

Branch naming conventions are often overlooked, but they’re crucial for repository hygiene. When all feature branches follow a pattern like feature/user-auth, it becomes trivial to identify what each branch is for. You can scan the branch list and understand what work is in progress. Your git history becomes readable.

Without conventions, you get branches named my-stuff, new-thing, fix, testing-branch, daniel-experiment. Looking at the branch list is useless. You can’t grep for related work. Automation scripts break because they can’t predict branch naming. Some branches never get deleted because it’s unclear if they’re important.

Conventions also enable automation. If you follow feature/*, fix/*, hotfix/* patterns, your CI/CD can make intelligent decisions. Feature branches might trigger a staging deployment. Hotfix branches might trigger production. If branch naming is predictable, automation becomes possible.

The hook enforces these conventions gently. It warns on the first push to a non-standard branch, but doesn’t block. The developer sees the warning, understands the pattern, and renames the branch or continues. After a few warnings, people internalize the convention. The next branch they create naturally follows the pattern.

Commit Message Validation Hook

Commit messages are often overlooked in safety, but they’re a core part of repository hygiene. A good commit-msg hook enforces standards without being pedantic. Good commit messages enable automated changelog generation, clear git history for future developers, and better context when investigating bugs.

// .git/hooks/commit-msg (Node implementation)


const RULES = {
  minLength: 10,
  maxLength: 100,
  allowedPrefixes: [
    "feat",
    "fix",
    "docs",
    "style",
    "refactor",
    "perf",
    "test",
    "chore",
  ],
  requirePrefix: true,
};

function validateCommitMessage() {
  const commitMsgFile = process.argv[2];
  const message = fs.readFileSync(commitMsgFile, "utf-8").trim();

  // Skip merge commits
  if (message.startsWith("Merge ")) {
    process.exit(0);
  }

  const firstLine = message.split("\n")[0];

  // Check length
  if (firstLine.length < RULES.minLength) {
    console.error("\n❌ COMMIT MESSAGE TOO SHORT\n");
    console.error(`Message: "${firstLine}"`);
    console.error(`Length: ${firstLine.length} (min: ${RULES.minLength})\n`);
    process.exit(1);
  }

  if (firstLine.length > RULES.maxLength) {
    console.error("\n❌ COMMIT MESSAGE TOO LONG\n");
    console.error(`Length: ${firstLine.length} (max: ${RULES.maxLength})\n`);
    process.exit(1);
  }

  // Check prefix (conventional commits)
  if (RULES.requirePrefix) {
    const prefixMatch = firstLine.match(/^(\w+)(\(.+\))?:/);
    if (!prefixMatch) {
      console.error("\n❌ MISSING COMMIT PREFIX\n");
      console.error("Expected format: type(scope): message\n");
      console.error("Examples:\n");
      console.error("  feat: add user authentication\n");
      console.error("  fix(auth): resolve login timeout\n");
      console.error("  docs: update API guide\n");
      process.exit(1);
    }

    const prefix = prefixMatch[1];
    if (!RULES.allowedPrefixes.includes(prefix)) {
      console.error("\n❌ INVALID COMMIT PREFIX\n");
      console.error(`Prefix: "${prefix}"\n`);
      console.error(
        "Allowed prefixes:",
        RULES.allowedPrefixes.join(", "),
        "\n",
      );
      process.exit(1);
    }
  }

  // Check for common issues
  if (
    firstLine.startsWith("WIP") ||
    firstLine.toLowerCase().includes("fixme")
  ) {
    console.warn("\n⚠️  WARNING: Unfinished work detected in message\n");
    // Advisory only; don't block
  }

  console.log("✅ Commit message validated\n");
  process.exit(0);
}

validateCommitMessage();

This hook enforces conventional commits, which makes your git history readable and enables automated changelog generation. Over time, having consistent commit messages pays enormous dividends. Future developers (including future you) will thank you when investigating bugs and they can understand context from the git log.

The Value of Conventional Commits

Commit message standards seem pedantic until you’ve spent an hour trying to understand why a bug was introduced by looking at commits like “fix”, “update”, and “more fixes”. Conventional commits with prefixes like feat:, fix:, docs: make your history self-documenting.

More importantly, conventional commits enable automation. Tools like semantic-release can read your commit history, automatically determine the new version number, and generate a changelog. Your release process becomes automated. Your version numbers have actual meaning (semantic versioning). Developers reading a changelog can understand exactly what changed.

When you have commits like feat(auth): add two-factor authentication, that message tells future developers:

  • This is a new feature, not a bug fix
  • It’s in the auth module
  • It adds two-factor authentication

That’s powerful information encoded in one sentence. It’s searchable. It’s parseable by tools. It creates institutional knowledge in your git history.

Configurable Protection Levels

Different projects have different needs. A startup might be aggressive with force pushes, while a banking system needs iron-clad protections. Let’s make the safety system configurable:

// git-safety-config.mjs (read at hook runtime)
export const PROTECTION_LEVELS = {
  STRICT: {
    allowForcePush: false,
    allowDirectCommits: false,
    enforceBranchNaming: true,
    maxFileSize: 1 * 1024 * 1024, // 1MB
    protectedBranches: ['main', 'master', 'production'],
  },
  MODERATE: {
    allowForcePush: true, // Only on feature branches
    allowDirectCommits: false,
    enforceBranchNaming: true,
    maxFileSize: 10 * 1024 * 1024, // 10MB
    protectedBranches: ['main', 'master', 'production'],
  },
  PERMISSIVE: {
    allowForcePush: true,
    allowDirectCommits: true, // But warn on protected branches
    enforceBranchNaming: false,
    maxFileSize: 100 * 1024 * 1024, // 100MB
    protectedBranches: [],
  },
};

export function loadConfig() {
  // Check for local override
  try {
    const localConfig = await import('./.git-safety-local.mjs');
    return localConfig.config || PROTECTION_LEVELS.MODERATE;
  } catch {
    // Default to MODERATE if no local config
    return PROTECTION_LEVELS.MODERATE;
  }
}

export function isProtectedBranch(branch, config) {
  return config.protectedBranches.includes(branch);
}

export function canForcePush(branch, config) {
  if (!config.allowForcePush) {
    return false;
  }
  // Allow force push on feature branches only
  return !isProtectedBranch(branch, config);
}

Teams can then create a .git-safety-local.mjs file to override defaults:

// .git-safety-local.mjs (committed to repo, per-project)


export const config = {
  ...PROTECTION_LEVELS.STRICT,
  protectedBranches: ["main", "master", "production", "staging"],
  // Allow force push on develop for experimentation
  allowForcePushOnDevelop: true,
};

This flexibility means one hook system can serve different project needs without modification.

Installing and Managing Hooks

Hooks need to be installed and kept in sync across the team. Here’s a setup script:

// scripts/install-git-hooks.mjs





const __dirname = path.dirname(fileURLToPath(import.meta.url));

const HOOKS = [
  { name: "pre-commit", file: "../hooks/pre-commit.mjs" },
  { name: "pre-push", file: "../hooks/pre-push.mjs" },
  { name: "commit-msg", file: "../hooks/commit-msg.mjs" },
];

function installHooks() {
  const gitDir = execSync("git rev-parse --git-dir").toString().trim();
  const hooksDir = path.join(gitDir, "hooks");

  if (!fs.existsSync(hooksDir)) {
    fs.mkdirSync(hooksDir, { recursive: true });
  }

  for (const { name, file } of HOOKS) {
    const source = path.resolve(__dirname, file);
    const dest = path.join(hooksDir, name);

    if (!fs.existsSync(source)) {
      console.warn(`⚠️  Hook not found: ${source}`);
      continue;
    }

    // Copy and make executable
    fs.copyFileSync(source, dest);
    fs.chmodSync(dest, 0o755);

    console.log(`✅ Installed ${name}`);
  }

  console.log("\n✨ Git safety hooks installed successfully!\n");
}

installHooks();

Run this during onboarding: node scripts/install-git-hooks.mjs

Real-World Scenarios: Common Situations and How Hooks Help

Let’s walk through some real situations where these hooks prevent disaster.

Scenario 1: The Exhausted Developer

It’s 6 p.m. You’ve been debugging a production issue for three hours. You finally find the problem in an old feature branch. You want to merge the fix to main immediately. Your fingers start typing git push --force origin main. The pre-push hook blocks it. You take a breath. You create a proper PR, get a quick review, and merge correctly. Crisis averted.

Scenario 2: The Accidental Secret

Your teammate is testing database credentials locally. They modify a .env file to point to production, make sure the test passes, then stage all their changes for a commit. They’re about to git commit when the pre-commit hook blocks the .env file. They unstage it, commit the actual code changes, and add .env to gitignore. The secret never makes it to git.

Scenario 3: The Binary Bloat

Someone accidentally commits a 200MB build artifact. The pre-commit hook catches it before it becomes a commit. They remove it, add the directory to .gitignore, and continue. Without the hook, that 200MB would be in git history forever, slowing everyone down.

Scenario 4: The Inconsistent Commit

A new developer makes their first commit with the message “fix”. The commit-msg hook rejects it, explains the format, and shows examples. They re-run with git commit --amend and use fix(auth): handle timeout error. Their commit message is now clear and parseable.

Common Pitfalls and How to Avoid Them

Hook Performance Issues

Hooks that take more than a few seconds to run train developers to bypass them with --no-verify. Keep hooks fast. Avoid spawning subprocesses unless necessary. Cache results aggressively. For pre-push hooks that might be slow, consider making non-critical checks non-blocking.

False Positives That Frustrate Teams

If your secret detection pattern is too broad, you’ll block legitimate commits and frustrate developers. Test patterns thoroughly. Review false positives quickly and adjust. If a pattern consistently blocks things that aren’t actually dangerous, remove it.

Hooks That Are Too Strict

A hook that blocks too much work becomes a productivity tax. Developers learn to bypass it. Balance safety with practicality. Allow overrides with clear audit trails. Let developers understand why something was blocked and provide alternatives.

Skipped Hooks in Critical Moments

When someone is firefighting a production issue, they’ll use --no-verify to skip hooks. That’s okay—acknowledge that emergency situations exist. Log these overrides. Build a culture where --no-verify is documented and exceptional, not routine.

The Human Side

The best safety system respects developers. Clear error messages, obvious escape hatches, and configurable protection levels mean people trust the system instead of resenting it. Your hooks should tell developers why something was blocked and how to do it safely. That builds a culture where safety is collaborative, not adversarial.

When someone bypasses a hook, that’s information. Talk about it. Maybe the rule is too strict. Maybe the developer didn’t understand the process. Either way, you’ve built transparency into your workflow. The goal isn’t to prevent developers from working—it’s to prevent costly mistakes that would hurt the whole team.

Over time, developers internalize these practices. They learn not to commit secrets because they know the hook will catch it. They start naming branches correctly because they understand the pattern. They write clear commit messages because they see the value. The hooks become background infrastructure that everyone respects.

Teams and Safety Culture: Making Hooks Part of Your Identity

The most successful teams using git safety hooks treat them as part of their development identity. They’re not seen as obstacles or bureaucracy—they’re seen as part of “how we work here.” This cultural shift doesn’t happen automatically. It requires intentional communication and design.

When you introduce safety hooks to a team, you’re essentially saying “we value these things enough to enforce them automatically.” This is a statement about what your organization believes. You believe secrets shouldn’t leak. You believe the main branch should only receive code that’s been reviewed. You believe commit messages should be clear. These aren’t arbitrary rules—they reflect your engineering values.

The most mature teams using hooks have made the reason for each hook explicit. New developers see the hook block something and instead of resenting the restriction, they read the documentation and understand why it exists. “Oh, we have branch naming conventions because it makes our git history readable and enables our CI/CD to make smart decisions. That makes sense.” The hook becomes a teacher, not a cop.

Teams also build grace periods into hook adoption. The first week a hook is introduced, you might run it in “warning-only” mode. Developers see the warnings but can still commit. This gives them time to learn the new rule, adjust their workflow, and ask questions. After the week, you switch to enforcement mode. By then, most developers have already internalized the practice. When the enforcement starts, it feels like the final step of a process they’ve been gradually learning.

This gradual adoption, combined with clear communication about why each rule exists, transforms hooks from something developers resent into something they respect. They learn that the hooks are protecting them from mistakes they’d rather not make. A hook that prevents committing secrets directly is saving them from a potential nightmare incident. A hook that enforces branch naming is making their git history navigable. Over time, you see developers recommending that their friends use the same hooks, contributing improvements to the hook system, and extending it with custom rules for their team.

Advanced Scenarios: When Hooks Meet Real Complexity

In real-world scenarios, git safety hooks encounter edge cases and complexity that the idealized version doesn’t capture. Let’s talk about what actually happens when you run sophisticated hook systems in production, because understanding these scenarios prevents the frustration of discovering them the hard way.

The legitimate force-push situation: A developer needs to do a force-push to a feature branch. They need to rewrite their local history before sending to code review. The hook blocks all force-pushes to protect main. The developer gets frustrated because the hook isn’t smart enough to distinguish between “force-push to main” (bad) and “force-push to my feature branch” (fine). The solution is making your hook smarter about context. Check which branch is being pushed to. Only block force-pushes if the target is a protected branch. This makes the rule more nuanced and reduces false positives.

The exception that proves the rule: Your team has a strict policy against credentials in code. But your test suite needs realistic-looking test data for integration tests. You need JWT tokens that parse correctly, database connection strings that match your actual format, API keys that validate properly. Legitimate test data looks exactly like real credentials. The solution is whitelisting directories or files. Your test directory matches a pattern, and the hook knows not to block credentials there. But you document this exception carefully, and you add a pre-commit check that prevents test credentials from ever reaching non-test code.

The changing requirements problem: Three months ago, your max file size limit was 5MB. That was reasonable for the codebase. But now you’re storing compiled WebAssembly modules, and they’re 20MB each. The old limit is now blocking legitimate work. You update the limit to 50MB. But the old limit was there for a reason—historically, you had repository performance issues. You’ve now unblocked a problem you solved. The solution is thinking about requirements holistically. Why was the limit 5MB? To prevent repository bloat and keep clone times reasonable. Those concerns don’t go away just because you have one legitimate use case for larger files. Instead of blanket increasing the limit, add context-aware sizing. Binary files have a higher limit. Text files have a lower limit. You make the rule more sophisticated rather than just making it weaker.

These scenarios require thinking about your hooks not as static rules but as living systems that evolve with your codebase and team. The best teams review their hooks quarterly. “Is this rule still necessary? Is it catching real problems or mostly false positives? Should we update it?” This continuous improvement prevents hooks from becoming either too lenient (defeating the purpose) or too strict (frustrating developers).


-iNet | Building safer workflows, one hook at a time.

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.