Git + Shell: Run Tooling Only on Files You Actually Changed
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.