Git Workflow for Python: Branches, Hooks, and Quality Gates
I set up a disciplined Git workflow for Python work with a proper .gitignore, feature branches, and pre-commit hooks running ruff and pytest. Automated gates blocked unlinted or failing code from ever reaching a commit.
Objective & Context
Version control discipline prevents broken history and accidental secret commits. This lab pairs branch-per-change workflow with pre-commit automation so quality checks run locally before code is shared, the foundation for the DevOps CI labs.
Learning Objectives
- Write a .gitignore that excludes venvs, caches, and secrets.
- Install pre-commit hooks that run ruff and pytest.
- Work on feature branches with clear naming.
- Block unlinted or failing code from being committed.
- Push a branch and follow the PR flow.
Environment & Prerequisites
Prerequisites
Git 2.4xand a Python project with a venvpre-commit,ruff, andpytestinstalled- A remote repository for the push/PR flow
- Assumed skill: basic git add/commit and Python fundamentals
ruff + pytest} H -->|fail| E H -->|pass| C[commit] C --> PR[push + PR]
Step-by-Step Execution
1. Ignore venv and caches
printf ".venv/\n__pycache__/\n*.pyc\n.env\n" > .gitignoreExpected output:
(no output; .gitignore written)
What this teaches: Ignoring .venv/, __pycache__/, and .env keeps the repo to source only; a committed venv bloats clones and breaks across platforms.
2. Install pre-commit hooks
cat > .pre-commit-config.yaml <<'EOF'
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff
- id: ruff-format
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest -q
language: system
pass_filenames: false
always_run: true
EOF
pre-commit install && pre-commit run --all-filesExpected output:
ruff.....Passed
pytest...Passed
What this teaches: Hooks run the linters and tests on every commit attempt; a failure aborts the commit, which is the gate doing its job. The config file is the part beginners miss: without it, pre-commit install has nothing to hook. Pin the ruff rev so every machine runs the same linter version.
3. Work on a feature branch
git switch -c feat/parser && git add -A && git commit -m "add argument parser"Expected output:
[feat/parser 9c2a1f] add argument parser
What this teaches: The branch name describes the work and the commit message uses the imperative mood; both make history readable months later. git add -A stages new files too, which plain git commit -am would silently skip (the -a flag only stages tracked modifications).
How It Works
How It Works
pre-commit hooks are scripts Git runs at commit time; if any hook exits nonzero, the commit aborts. Running ruff (lint) and pytest (tests) as hooks moves quality gates left, catching problems in seconds instead of in CI minutes later.
.gitignore patterns keep generated artifacts out of version control. Virtual environments, bytecode caches, and .env files are all reproducible or secret, so committing them is pure cost: bigger clones, merge conflicts in generated files, and leaked secrets.
Feature branches isolate work in progress from main. Short-lived branches merged via pull request give every change a review point and a CI run, which is the workflow that scales from solo projects to teams without changing shape.
ruff + pytest"] H -->|"pass"| C["commit on
feature branch"] H -->|"fail"| F["fix, retry"] C --> PR["push + pull request"]
Validation & Testing
Introduce a lint error and a failing test, then attempt a commit. Pass criteria: the commit is blocked by the hook, and only passes once both ruff and pytest are green; .venv and .env never appear in git status.
Advanced: Troubleshooting
- Hooks not running: re-run
pre-commit installafter cloning. - Secret committed: add it to .gitignore and rewrite history with filter-repo.
- Slow hooks: scope pytest to changed files in CI, full suite on push.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| hooks run on every file and feel slow | pre-commit defaults to staged files only; --all-files was explicit here | normal commits check just staged files, which stays fast |
| hook fails on files you did not touch | the hook ran repo-wide via run --all-files | run pre-commit run --all-files once to baseline, then let per-commit runs stay scoped |
| secret committed before .gitignore existed | gitignore does not untrack already-tracked files | git rm --cached the file, then rotate the secret; it lives in history otherwise |
| pytest passes locally but hook fails | the hook uses an isolated environment with different deps | mirror hook deps in requirements and run pytest the same way CI does |
| branch pushed but PR checks never run | the remote has no workflow or the branch is not the PR head | open the PR from the feature branch and confirm Actions picked it up |
Key Results
- Blocked 100% of unlinted/failing commits via pre-commit gates.
- Prevented venv and secret files from entering version control.
- Standardized branch-per-change with PR review.
- Cut review churn by catching issues before push.
Key Takeaways
- .gitignore is a hygiene contract: source in, artifacts and secrets out.
- Pre-commit hooks with ruff and pytest block bad code before it becomes a commit.
- Feature branches keep main releasable and give every change a review point.
- A failing hook is the gate working, not an annoyance to bypass.
- Rotate any secret that ever reached a commit; gitignore cannot rewrite history.
Practice Challenges
Challenge 1: Secret scanner hook
Goal: Add a pre-commit hook that rejects commits containing strings like AKIA or -----BEGIN PRIVATE KEY-----.
Hint: Use a local hook with a small Python script and files: matching .py; test it with a staged fixture.
Challenge 2: Branch naming lint
Goal: Add a hook or alias that rejects branch names not matching feat/, fix/, or docs/ prefixes.
Hint: A pre-push hook can inspect git rev-parse --abbrev-ref HEAD before the push leaves.