DevOpsDXPhilosophy

From FTP Yolo to Pre-Commit Flow

A 30-Year Journey to Stop Breaking Production

On feedback loops, the economics of quality, and building gates that developers actually appreciate—a reflection after three decades of getting it wrong first.

20 min read

I still remember the first time I broke a production system. It was 1998, I was maybe twenty-something, working on what passed for a "web application" back then—mostly Perl CGI scripts held together with optimism and regex. I'd made a change, tested it locally (which meant refreshing my browser twice), and FTPed it straight to the live server. Within minutes, the error logs were screaming.

The bug was trivial. A missing semicolon, if I recall correctly. The kind of thing any linter would catch instantly today. But there was no linter. There was no CI. There wasn't even version control on that project—just a folder called old_backup_DONT_DELETE that I prayed contained something resembling the previous working state.

We've come a long way. And yet, I still see teams shipping code without pre-commit hooks, relying entirely on CI pipelines that take 20 minutes to tell them what they could have known in 2 seconds. Not because they don't care about quality, but because nobody ever explained why this stuff matters and where it fits in the broader picture of building software that doesn't fall apart.

So let's talk about that. Not just the "how to install Husky" tutorial (we'll get there), but the philosophy underneath. Why do we have quality gates at all? Where should they live? And how did we arrive at this particular stack that's become the de facto standard in the JavaScript/TypeScript ecosystem?

Part 0: The Philosophy of Feedback Loops

Here's the fundamental insight that took me years to internalize: the cost of fixing a bug is not constant. It's a function of when you find it.

A typo caught by your editor's red squiggle costs you nothing—maybe a keystroke to fix. The same typo caught in code review costs you context switching, a round-trip conversation, and possibly a bruised ego. Caught in CI? Now you're waiting 15 minutes for a build, plus the cognitive overhead of "what was I even working on?" Caught in production? Now we're talking incident response, customer impact, maybe a 2 AM page.

Relative Cost of Fixing Defects (IBM Systems Sciences Institute)

Design
1x
Implementation
5x
Testing
10x
Maintenance
100x

Pre-commit hooks catch issues at the implementation boundary—before they become "testing" or "maintenance" problems.

This is why the software industry has been obsessed with "shifting left" for the past decade. The term comes from imagining your development process as a timeline flowing left to right—from idea to production. Shifting left means moving quality checks earlier in that timeline. Catching problems before they compound.

Modern software development has multiple feedback loops, each with different latencies and costs. Understanding this hierarchy is key to placing your quality gates intelligently:

The Feedback Loop Spectrum

Earlier = Cheaper. Hover or tap to explore each stage.

Cost of fixing bugs increases ~10x at each stage. Pre-commit sits at the sweet spot: automated, fast, pre-history.

Pre-commit hooks occupy a special position in this hierarchy. They're the last automated checkpoint before code becomes part of your project's permanent history. They're fast enough to not disrupt flow (if implemented correctly), yet comprehensive enough to catch the majority of issues that would otherwise waste CI time or pollute your git log.

Part 0.5: A Brief History of Quality Gates

Before we dive into the modern stack, it's worth understanding how we got here. Quality gates aren't new—what's new is their accessibility and integration into the development workflow.

In the early days (and I'm talking early-to-mid 90s here, when I was cutting my teeth on Pascal and C), code quality was entirely manual. You had:

  • Compilers that caught syntax errors (if you were lucky enough to use a compiled language)
  • Code reviews done by printing code on paper and marking it up with red pen
  • Style guides that nobody followed consistently
  • Testing that meant "run the program and see if it crashes"

Version control existed (CVS, RCS, SCCS if you go back far enough), but it was primarily for backup and history, not workflow automation. The idea of a "hook" that ran automatically on commit was exotic.

The Modern Stack: An Overview

With the philosophy and history established, let's look at the actual stack. The canonical combination looks like this:

Huskylint-stagedESLint/Prettier/TypeScriptcommitlint

Each component has a specific role. Understanding these roles—and why the responsibilities are divided this way—is key to configuring the stack correctly and debugging it when things go wrong.

The Modern Pre-Commit Pipeline

Husky

Hook Management

Solves the ".git/hooks isn't version controlled" problem. Ensures every developer has the same hooks.

lint-staged

Selective Execution

Makes pre-commit fast by only checking staged files. The key to developer adoption.

ESLint

Code Quality

Catches bugs, enforces patterns, prevents footguns. The "is this code correct?" check.

Prettier

Formatting

Ends style debates. The "does this code look consistent?" check.

TypeScript

Type Safety

Catches type errors at compile time. The "will this code run?" check.

commitlint

Message Validation

Enforces commit message conventions. Enables automated changelogs and semantic versioning.

Part I: Husky — The Hook Orchestrator

Git hooks are shell scripts living in .git/hooks/. They've existed since Git's inception—I remember using them with Subversion's predecessor, CVS, in a more primitive form. The concept is simple: run arbitrary code at specific points in the Git workflow.

Here's the fundamental issue: the .git directory is intentionally excluded from version control. It's local state—your branches, your stashes, your hooks.

This creates a bootstrapping paradox. You want every developer on your team to run the same pre-commit checks. But you can't commit the hooks themselves. So you end up with:

  • A README that says "please run ./scripts/install-hooks.sh"
  • Which nobody reads
  • So half the team doesn't have hooks installed
  • So the hooks catch nothing
  • So eventually someone removes them because "they don't work anyway"

I've seen this cycle play out at multiple companies. Husky breaks the cycle by hooking into npm's lifecycle scripts.

How Husky Works
bash
# Modern Husky (v9+) installation
npm install --save-dev husky
npx husky init

# What this creates:
# 1. A "prepare" script in package.json that runs "husky"
# 2. A .husky/ directory with your hook scripts
# 3. Git configured to use .husky/ as the hooks directory

# The magic: "prepare" runs automatically after "npm install"
# So every developer gets hooks installed without thinking about it

Part II: lint-staged — The Performance Multiplier

Here's where the real insight lives. Running ESLint across an entire codebase on every commit is a developer experience catastrophe. I've worked on codebases where a full lint took 45+ seconds. Try that a few times and you'll start reaching for --no-verify reflexively.

lint-staged solves this elegantly: it operates only on files that are staged for commit. You changed three files? It lints three files. Not three thousand.

MetricWithout StackWith StackImprovement
Full repo lint45s2.3s95%
Type check12s3.1s74%
CI failures caught locally0%~85%
Developer frustrationHighLowN/A

lint-staged embodies a principle that's easy to overlook: a quality gate's effectiveness is determined by its adoption rate, not its thoroughness.

A comprehensive check that developers bypass is worse than a partial check that developers actually use. This is counterintuitive to the "more checks = better" mindset, but it's borne out by experience.

By making pre-commit fast enough to be imperceptible on small changes, lint-staged ensures that the checks actually run. The full, exhaustive verification still happens—but in CI, where latency is expected.

Part III: ESLint + Prettier + TypeScript — The Quality Trinity

These three tools form the actual quality checking layer. Understanding their distinct responsibilities is crucial—conflating them leads to configuration hell and redundant work.

ESLint

Focus: Code Quality & Patterns

Asks: "Is this code correct and idiomatic?"

Catches: Unused variables, missing deps in useEffect, accessibility issues, potential bugs

Prettier

Focus: Formatting (Cosmetic)

Asks: "Does this code look consistent?"

Catches: Indentation, line width, quote style, semicolons, trailing commas

TypeScript

Focus: Type Safety

Asks: "Will this code run without type errors?"

Catches: Type mismatches, missing properties, incorrect function signatures

There's historical tension here. ESLint has formatting rules (indentation, quotes, etc.). Prettier also handles formatting. Running both without coordination causes conflicts and wasted cycles—one tool reformats, the other complains about the result.

The modern consensus: Prettier owns formatting, ESLint owns everything else.

eslint.config.js (flat config)
javascript
import eslintConfigPrettier from 'eslint-config-prettier';
import tseslint from '@typescript-eslint/eslint-plugin';

export default [
  // Your TypeScript config
  ...tseslint.configs.recommended,

  // Your custom rules
  {
    rules: {
      'no-console': 'warn',
      // ... quality rules, NOT formatting rules
    }
  },

  // THIS MUST BE LAST
  // Disables all ESLint rules that conflict with Prettier
  eslintConfigPrettier,
];

eslint-config-prettier is just a config that sets a bunch of rules to "off". It must be last so it overrides any formatting rules from other configs.

Part IV: commitlint — Structured History as Documentation

This is the component people most often skip, and I get it. Commit message linting feels like bureaucracy. "Just let me write 'fixed bug' and move on."

But here's the thing: your commit history is documentation. It's the story of how your codebase evolved. And like all documentation, it's either useful or it's noise.

I've done archaeology on codebases with decades of history. The ones with structured commits are comprehensible. The ones with messages like "stuff" and "wip" and "fix fix fix" are nearly useless.

Structured commits enable:

  • Automated changelogs: Tools like semantic-release can generate CHANGELOGs directly from commit history
  • Semantic versioning automation: feat → minor bump, fix → patch, ! → major
  • Filtered git log: git log --grep="^feat(auth)" shows only auth features
  • Faster code review: The commit message tells you what to expect before you read the diff
  • Bisect effectiveness: When hunting bugs with git bisect, good messages help you skip irrelevant commits

Complete Reference: Copy-Paste Ready

Here's everything assembled. Feel free to adapt to your specific needs:

1. Install Dependencies
bash
npm install --save-dev \
  husky \
  lint-staged \
  eslint \
  prettier \
  eslint-config-prettier \
  @commitlint/cli \
  @commitlint/config-conventional \
  typescript
2. Initialize Husky
bash
npx husky init

# Add commit-msg hook for commitlint
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
chmod +x .husky/commit-msg
3. package.json
json
{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "typecheck": "tsc --noEmit"
  }
}
4. lint-staged.config.js
javascript
export default {
  '*.{ts,tsx,js,jsx}': [
    'eslint --fix --max-warnings=0',
    'prettier --write',
  ],
  '*.{json,yaml,yml,md,css,scss}': 'prettier --write',
  '*.{ts,tsx}': () => 'tsc --noEmit',
};
5. commitlint.config.js
javascript
export default {
  extends: ['@commitlint/config-conventional'],
};
6. .husky/pre-commit
bash
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

The Deeper Point

This stack embodies a principle I've come to believe deeply after thirty-odd years of writing software: fail fast, fail locally, fail cheaply.

Every bug caught in pre-commit is a bug that doesn't pollute your Git history, doesn't consume CI minutes, doesn't block your teammates' code review, doesn't interrupt someone's flow with a "hey, your build is broken" message.

But—and this is crucial—the stack must be fast enough that developers don't bypass it. That's why lint-staged exists. That's why we parallelize where possible. That's why we check only what changed. A quality gate that developers hate becomes a quality gate that developers disable.

The best tooling is invisible. It stays out of your way until the moment it saves you from yourself. Then you thank past-you for taking the time to set it up.

Written with the hard-won wisdom of thirty years of forgetting semicolons.

Questions? Corrections? Find me arguing about build tools on the internet.

An interactive guide to modern pre-commit tooling

← Back to Articles
From FTP Yolo to Pre-Commit Flow | ASleekGeek