Git + Shell: Run Tooling Only on Files You Actually Changed

calendar_today Mar 26, 2026
schedule 6 min read

Here’s a command I’ve been reaching for a lot lately:

git log -n 5 --pretty="" --name-only | grep '\.go$' | sort -u | xargs -n 1 go fmt

It gets the files changed in the last 5 commits, filters to Go files, deduplicates, and runs go fmt on each one. No config files, no vendored dependencies, no files you haven’t touched in months — just the code you’re actually working on.

It sounds like a small thing but it changes how you reach for your tools.

This is part of an ongoing series of shell and git patterns I’ve found useful while building. No fluff, just commands that earn their place.


Breaking It Down

git log -n 5 --pretty="" --name-only

-n 5 limits to the last 5 commits. --pretty="" suppresses commit metadata — no hashes, no author lines, no dates. --name-only outputs just the file paths. The result is a clean list of every file touched across those 5 commits.

| grep '\.go$'

Filter to Go files only. The \. escapes the dot so it’s a literal character, not a regex wildcard. The $ anchors to end of line so it won’t accidentally match something like config_go_template.yaml.

| sort -u

If you touched ratelimiter.go in three of the five commits, it appears three times in the output. sort -u deduplicates so each file is only processed once.

| xargs -n 1 go fmt

xargs takes each line and passes it as an argument to go fmt. -n 1 means one file per invocation — some tools handle multiple arguments fine, others don’t, so this is the safe default.


The Fix Worth Making

The original command uses grep .go — the dot here is a regex wildcard, not a literal period. It works in practice but it’s imprecise. The safe version:

git log -n 5 --pretty="" --name-only | grep '\.go$' | sort -u | xargs -n 1 go fmt

Small change, more correct.


Other Use Cases

The pattern is git log | grep | sort -u | xargs <tool>. The tool is the only thing that changes.

Linting with golangci-lint

git log -n 5 --pretty="" --name-only | grep '\.go$' | sort -u | xargs -n 1 golangci-lint run

Running golangci-lint on your entire repo on every save is slow. Running it only on files you’ve changed is fast enough to become a habit. Pair this with a git pre-push hook and you’ll never push unlinted code again.

For a pre-push hook, add this to .git/hooks/pre-push:

#!/bin/sh
git log -n 10 --pretty="" --name-only | grep '\.go$' | sort -u | xargs -n 1 golangci-lint run

Running Tests on Changed Packages

git log -n 5 --pretty="" --name-only | grep '\.go$' | sort -u | xargs -n 1 dirname | sort -u | xargs go test

We add dirname to the pipeline to extract the package directory from each file path, deduplicate again, then pass the directories to go test. This runs tests for every package you’ve touched rather than every file — which is what Go’s test runner expects.

If a file is at internal/ratelimiter/limiter.go, this runs go test ./internal/ratelimiter/.

Security Scanning with govulncheck

git log -n 5 --pretty="" --name-only | grep '\.go$' | sort -u | xargs -n 1 dirname | sort -u | xargs govulncheck

Same dirname trick as tests. govulncheck operates at the package level so we extract directories first. Useful as a lightweight pre-commit check without scanning your entire module on every run.

Generating Mocks

If you use mockgen and your interfaces live in dedicated files:

git log -n 5 --pretty="" --name-only | grep '_interface\.go$' | sort -u | xargs -n 1 go generate

Adjust the grep pattern to match your naming convention — _interface.go, interfaces.go, whatever you use. This regenerates mocks only for interface files you’ve actually changed, rather than running go generate ./... across the entire codebase.


Other Languages

The pattern isn’t Go-specific. The git log side stays the same — only the grep filter and the tool change.

Rust

# Format changed Rust files
git log -n 5 --pretty="" --name-only | grep '\.rs$' | sort -u | xargs -n 1 rustfmt

# Clippy on changed crates
git log -n 5 --pretty="" --name-only | grep '\.rs$' | sort -u | xargs -n 1 dirname | sort -u | xargs -I {} cargo clippy --manifest-path {}/Cargo.toml

Python

# Format with black
git log -n 5 --pretty="" --name-only | grep '\.py$' | sort -u | xargs black

# Lint with ruff
git log -n 5 --pretty="" --name-only | grep '\.py$' | sort -u | xargs ruff check

black and ruff both handle multiple file arguments cleanly so you can drop the -n 1 from xargs and let them batch.

JavaScript / TypeScript

# Prettier
git log -n 5 --pretty="" --name-only | grep -E '\.(ts|tsx|js|jsx)$' | sort -u | xargs npx prettier --write

# ESLint
git log -n 5 --pretty="" --name-only | grep -E '\.(ts|tsx|js|jsx)$' | sort -u | xargs npx eslint --fix

Making It Reusable

Typing this out every time gets old. A few ways to make it stick.

Shell function

Add this to your .zshrc or .bashrc:

git_changed() {
  local ext="${1:-.go}"
  local n="${2:-5}"
  git log -n "$n" --pretty="" --name-only | grep "\\.$ext\$" | sort -u
}

Then use it like:

git_changed go | xargs -n 1 go fmt
git_changed rs | xargs -n 1 rustfmt
git_changed py | xargs black

Git alias

Add to your ~/.gitconfig:

[alias]
  changed-files = "!f() { git log -n ${1:-5} --pretty='' --name-only | sort -u; }; f"

Then:

git changed-files | grep '\.go$' | xargs -n 1 go fmt

When to Reach for This

This pattern is most useful when:

  • Your repo is large enough that running tools on everything is noticeably slow
  • You want a lightweight pre-push or pre-commit check without a full CI run
  • You’re doing a focused refactor across a handful of files and want tight feedback

It’s not a replacement for running your full test suite and linter in CI — that still needs to cover everything. Think of this as a fast inner loop tool for local development, not a correctness guarantee.


More shell and git patterns coming in this series. If you found this useful, the rate limiter series is worth a read too — same philosophy, different domain.