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.4x and a Python project with a venv
  • pre-commit, ruff, and pytest installed
  • A remote repository for the push/PR flow
  • Assumed skill: basic git add/commit and Python fundamentals

Step-by-Step Execution

1. Ignore venv and caches

printf ".venv/\n__pycache__/\n*.pyc\n.env\n" > .gitignore

Expected 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-files

Expected 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.

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 install after 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

SymptomLikely causeFix
hooks run on every file and feel slowpre-commit defaults to staged files only; --all-files was explicit herenormal commits check just staged files, which stays fast
hook fails on files you did not touchthe hook ran repo-wide via run --all-filesrun pre-commit run --all-files once to baseline, then let per-commit runs stay scoped
secret committed before .gitignore existedgitignore does not untrack already-tracked filesgit rm --cached the file, then rotate the secret; it lives in history otherwise
pytest passes locally but hook failsthe hook uses an isolated environment with different depsmirror hook deps in requirements and run pytest the same way CI does
branch pushed but PR checks never runthe remote has no workflow or the branch is not the PR headopen 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.