Why use a Makefile for Git?
Short Make targets reduce typing, enforce consistent flags, and provide a discoverable, project-local interface for frequent Git tasks. This is handy for DevOps teams standardizing workflows across repositories.
- One-liners for common actions
- Self-documenting help target
- Consistent flags and safety checks
- Works everywhere Git and make are available
Minimal working example
Save this Makefile at the root of a Git repository.
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
GIT ?= git
REPO_ROOT := $(shell $(GIT) rev-parse --show-toplevel 2>/dev/null)
BRANCH := $(shell $(GIT) rev-parse --abbrev-ref HEAD 2>/dev/null)
# Commit message; can be overridden: make commit M="fix: message"
M ?= chore: update
export M
.PHONY: help status lg commit push pull sync switch
help: ## Show available targets
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | awk -F':|##' '{printf " %-12s %s\n", $$1, $$NF}'
status: ## Git status summary (short)
@$(GIT) -C "$(REPO_ROOT)" status -sb
lg: ## Pretty one-line log (recent)
@$(GIT) -C "$(REPO_ROOT)" log --oneline --graph --decorate -n 20
commit: ## Commit staged changes with message M="..."
@$(GIT) -C "$(REPO_ROOT)" commit -m "$$M"
push: ## Push current branch to origin
@$(GIT) -C "$(REPO_ROOT)" push -u origin "$(BRANCH)"
pull: ## Fast-forward only pull
@$(GIT) -C "$(REPO_ROOT)" pull --ff-only
sync: ## Rebase onto origin/BRANCH and push
@$(GIT) -C "$(REPO_ROOT)" fetch origin
@$(GIT) -C "$(REPO_ROOT)" rebase "origin/$(BRANCH)"
@$(GIT) -C "$(REPO_ROOT)" push -u origin "$(BRANCH)"
switch: ## Switch to branch B="name" (create with -c via GITFLAGS)
@: $${B?Set B="branch-name"}
@$(GIT) -C "$(REPO_ROOT)" switch "$$B"
Notes:
- Use a real tab before each recipe line.
- M is exported so quotes and spaces in commit messages work: make commit M="fix: handle spaces".
- sync uses rebase; adjust to your policy.
Quickstart
- Create a file named Makefile in your repo with the example above.
- Run make help to see available targets.
- Stage changes normally (git add -p or git add .).
- Commit: make commit M="feat: add health endpoint".
- Push: make push.
- Sync with remote (fetch, rebase, push): make sync.
- Switch branch: make switch B=feature/authz.
Common targets at a glance
| Target | What it does | Example usage |
|---|---|---|
| help | List documented targets | make help |
| status | Short status with branch info | make status |
| lg | One-line decorated log | make lg |
| commit | Commit staged changes with message M | make commit M="fix: flaky test" |
| push | Push current branch to origin | make push |
| pull | Pull with fast-forward only | make pull |
| sync | Fetch, rebase current branch on origin, then push | make sync |
| switch | Switch to branch B | make switch B=main |
Extending the Makefile
Add more repo-specific helpers to standardize your Git flow.
.PHONY: new-branch amend tag prune-local
new-branch: ## Create and switch to new branch B="name"
@: $${B?Set B="branch-name"}
@$(GIT) -C "$(REPO_ROOT)" switch -c "$$B"
amend: ## Amend last commit with staged changes (keep message)
@$(GIT) -C "$(REPO_ROOT)" commit --amend --no-edit
tag: ## Create annotated tag T and push (T="v1.2.3")
@: $${T?Set T="vX.Y.Z"}
@$(GIT) -C "$(REPO_ROOT)" tag -a "$$T" -m "$$T"
@$(GIT) -C "$(REPO_ROOT)" push origin "$$T"
prune-local: ## Remove local branches merged into origin/main (danger)
@main=main; \
merged=$$($(GIT) -C "$(REPO_ROOT)" branch --merged origin/$$main | grep -v "\*" | grep -v " $$main$$" || true); \
[ -z "$$merged" ] || xargs -r -n1 $(GIT) -C "$(REPO_ROOT)" branch -d <<< "$$merged"
Tips:
- Add CI-safe variations (e.g., read-only targets for logging or validation).
- Use environment-gated behavior, e.g., ifeq ($(CI),true) to avoid destructive actions in pipelines.
Pitfalls and how to avoid them
- Tabs vs spaces: Make recipes must start with a tab. Many editors can insert tabs automatically for Makefiles.
- Quoting commit messages: Export M and reference it as "$${M}" in recipes; avoid -m $(M) which breaks on spaces.
- Running outside a Git repo: Commands using -C "$(REPO_ROOT)" fail gracefully. Optionally add a guard line: test -n "$(REPO_ROOT)" || { echo "Not a git repo"; exit 1; }.
- Destructive targets: Mark dangerous commands clearly and add confirmation prompts or no-op dry runs. Prefer --ff-only for pulls; be explicit about rebase vs merge policies.
- Cross-platform shells: GNU make defaults to /bin/sh; set SHELL and .SHELLFLAGS for predictable behavior. On Windows, use Git Bash or WSL.
- Target/file name clashes: Use .PHONY for all command targets so make doesn’t think a file named “push” satisfies the rule.
Performance notes
- $(shell ...) calls run at parse time. Use := for single evaluation (as shown) so you don’t repeatedly call Git.
- The overhead of make is tiny compared to Git. Group related operations (e.g., fetch + rebase) to avoid multiple invocations where helpful.
- Avoid complex loops when native Git subcommands exist (e.g., use git worktree, git for-each-ref) to keep recipes fast and robust.
- Use -C "$(REPO_ROOT)" to avoid subshell cd overhead and to ensure commands run at the repo root.
Tiny FAQ
- Can I alias make targets to shorter names? Yes. Add short aliases, e.g., st: status; with an empty recipe and a dependency on status.
- Why not Git aliases? Git aliases are great but local to a user. Makefiles travel with the repo, enabling team-wide conventions and CI reuse.
- How do I pass variables with spaces? Quote them at the shell: make commit M="fix: handle edge case". M is exported and read as one argument.
- Should sync use rebase or merge? Choose per team policy. Rebase creates a linear history; merge preserves merge commits. Adjust the sync target accordingly.