6. Git for GenAI projects¶
Intermediate · 14 min read
AI projects hit Git problems ordinary web apps rarely do: API keys that cost real money when leaked, multi-gigabyte model weights and embeddings, notebooks that produce unreadable diffs, and prompts that behave like code but don't look like it. This page is the practical playbook.
6.1 A .gitignore for AI projects¶
# secrets: commit a .env.example with empty values instead
.env
.env.*
!.env.example
*.pem
credentials*.json
# Python
.venv/
venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.mypy_cache/
# notebooks
.ipynb_checkpoints/
# data, models and generated stores: too big, or rebuildable
data/raw/
models/
*.safetensors
*.gguf
*.bin
*.pt
chroma_db/
*.faiss
*.lance/
mlruns/
wandb/
# app output
logs/
*.log
uploads/
# editors and OS
.vscode/*
!.vscode/settings.json
.idea/
.DS_Store
Thumbs.db
The rule of thumb: commit what's needed to rebuild the project (code, configs, prompts, small eval sets, lock
files), not what can be rebuilt or downloaded (venvs, vector indexes, model weights, caches). Always commit the lock
file (uv.lock, poetry.lock) so everyone gets the same dependency versions.
6.2 When a secret gets committed¶
It happens to everyone eventually. The order of steps matters:
- Revoke and rotate the key immediately in the provider's console (OpenAI, Anthropic, AWS…). Assume it's already been copied: bots scan public GitHub for keys within minutes of a push. This is the only step that actually protects you.
- Remove it from the code: load it from the environment instead, and add the file to
.gitignore. -
If it was pushed, remove it from history too, because deleting the file in a new commit leaves the key in every old commit:
-
Check what else was exposed (provider usage dashboards, billing) and tell your team or security contact.
Rewriting history is disruptive and doesn't un-leak anything (forks, clones and caches keep the old commits), which
is why step 1 comes first and why prevention (6.3) is worth so much. If the commit was never pushed, it's easy:
git reset --soft HEAD~1, unstage the file, and commit again.
6.3 Catch secrets before they're committed¶
GitHub's push protection blocks pushes containing many known key formats; turn it on for every repository. Locally, a pre-commit hook catches them even earlier. Here's the core of one: scan the lines being added in a diff for key-shaped strings.
import re
PATTERNS = {
"OpenAI key": r"sk-(?:proj-)?[A-Za-z0-9_-]{20,}",
"Anthropic key": r"sk-ant-[A-Za-z0-9_-]{20,}",
"AWS access key": r"AKIA[0-9A-Z]{16}",
"GitHub token": r"gh[pousr]_[A-Za-z0-9]{36}",
"Hugging Face": r"hf_[A-Za-z0-9]{30,}",
"Private key": r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
"Generic assignment": r"(?i)(api[_-]?key|secret|token|password)\s*=\s*['\"][^'\"]{12,}['\"]",
}
def scan_diff(diff: str) -> list[str]:
findings, current_file, line_no = [], "?", 0
for line in diff.splitlines():
if line.startswith("+++ b/"):
current_file = line[6:]
elif line.startswith("@@"):
line_no = int(re.search(r"\+(\d+)", line).group(1)) - 1
elif line.startswith("+") and not line.startswith("+++"):
line_no += 1
for name, pattern in PATTERNS.items():
if re.search(pattern, line):
findings.append(f"{current_file}:{line_no}: {name}")
elif not line.startswith("-"):
line_no += 1
return findings
staged_diff = """\
diff --git a/rag/config.py b/rag/config.py
--- a/rag/config.py
+++ b/rag/config.py
@@ -1,3 +1,5 @@
import os
+OPENAI_API_KEY = "sk-proj-4f9Kq2LmZx81TbVw0RsYp7Hn"
+EMBED_MODEL = "text-embedding-3-small"
TOP_K = 5
+HF_TOKEN = os.getenv("HF_TOKEN")
diff --git a/deploy/aws.py b/deploy/aws.py
--- a/deploy/aws.py
+++ b/deploy/aws.py
@@ -10,2 +10,3 @@
REGION = "ap-south-1"
+ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
"""
findings = scan_diff(staged_diff)
print("\n".join(findings) if findings else "clean")
print("commit blocked" if findings else "commit allowed")
rag/config.py:2: OpenAI key
rag/config.py:2: Generic assignment
deploy/aws.py:11: AWS access key
commit blocked
HF_TOKEN = os.getenv("HF_TOKEN") passed: reading a key from the environment is exactly what you want. In a real
hook you'd feed it git diff --cached and exit with code 1 to block the commit. In practice, use a maintained
scanner such as gitleaks or detect-secrets through the pre-commit framework (6.5), which know hundreds of
formats and check entropy too.
6.4 Large files: models, datasets, vector indexes¶
Git keeps every version of every file forever in every clone. Commit a 2 GB model once, delete it, and every clone still downloads 2 GB. GitHub rejects files over 100 MB outright.
| Tool | How it works | Use it for |
|---|---|---|
| Git LFS | Git stores a small pointer; the file lives on an LFS server | A few large binaries that change rarely (a fine-tuned adapter, test fixtures) |
| DVC | Git stores a .dvc metadata file; data lives in S3, GCS, Azure or a drive |
Datasets and model artifacts with pipelines and versioning |
| Hugging Face Hub | Model and dataset repos (Git + LFS underneath) with a Python API | Sharing models and datasets; download by name and revision |
| Object storage + a manifest | Files in S3; the repo stores paths and checksums | Large, frequently rebuilt artifacts like vector indexes |
git lfs install
git lfs track "*.safetensors" # writes a rule to .gitattributes; commit that file
git add .gitattributes adapters/support-lora.safetensors
git commit -m "Add support LoRA adapter (LFS)"
For RAG projects, don't commit the vector store. Commit the ingestion code, the chunking and embedding config, and a manifest of source documents, then rebuild the index in CI or at deploy time. That way, changing the chunk size is a reviewable diff instead of an opaque 800 MB binary.
6.5 Notebooks and pre-commit¶
A Jupyter notebook is JSON with outputs, images and execution counts embedded. Every run changes it, diffs are unreadable, and outputs can contain data or keys you printed. Strip outputs before committing:
pip install nbstripout
nbstripout --install # adds a Git filter: outputs are removed from what gets committed
Better still, move code that matters out of notebooks into .py modules, and keep notebooks for exploration.
The pre-commit framework runs checks like this on every commit. One config file covers formatting, linting, secrets and notebooks:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff # lint (with autofix)
args: [--fix]
- id: ruff-format # format
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks # block secrets
- repo: https://github.com/kynan/nbstripout
rev: 0.7.1
hooks:
- id: nbstripout # clean notebooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
args: [--maxkb=5000]
- id: end-of-file-fixer
pip install pre-commit
pre-commit install # run the hooks on every git commit
pre-commit autoupdate # bump the rev: pins to the latest versions
pre-commit run --all-files # check the whole repo once
6.6 Version prompts and evals like code¶
Prompts change model behaviour as much as code does, so they belong in Git, where they get diffs, review and history:
chat-app/
├── app/
├── prompts/
│ ├── support_system.md # one prompt per file: readable diffs, CODEOWNERS per folder
│ └── query_rewrite.md
├── evals/
│ ├── support_cases.jsonl # small, curated eval set: commit it
│ └── run_evals.py # exits non-zero below the threshold
└── tests/
- One prompt per file, loaded at runtime, so a prompt change is a one-file diff a reviewer can read.
- Log the Git commit hash (or tag) with every LLM call, so any answer can be traced to the exact prompt and code that produced it.
- Small eval sets go in Git. Large ones go in DVC or the Hub, pinned by version.
- Every prompt change runs the evals in CI before it can merge (6.7).
See Testing & versioning prompts for the prompt side of this.
6.7 CI with GitHub Actions¶
A workflow file in .github/workflows/ runs on every pull request. This one lints, runs unit tests, and runs the
eval suite with an API key stored as a repository secret:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: ruff check . && ruff format --check .
- run: pytest -q
evals:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # costs API money: only on PRs
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: python evals/run_evals.py --min-score 0.85
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- Secrets are masked in logs and not available to PRs from forks, so a stranger's PR can't steal your key.
- Mark the
testandevalsjobs as required checks in branch protection (page 5). - Keep the CI eval set small and cheap; run the full suite nightly with a
schedule:trigger. - More on this in Running evals in CI.
Interview questions¶
You accidentally pushed an OpenAI API key to GitHub. What do you do?
Revoke and rotate the key immediately, because it may already have been scraped. Then remove it from the code
(load from environment variables), add the file to .gitignore, purge it from history with git filter-repo
and force-push if the repo requires it, check usage and billing for abuse, and add prevention: push protection
and a secret-scanning pre-commit hook.
Why shouldn't you commit model weights or a vector index to Git?
Git stores every version of every file in every clone forever, so large binaries bloat the repository permanently (GitHub also rejects files over 100 MB), and binary diffs can't be reviewed. Use Git LFS, DVC, the Hugging Face Hub or object storage for artifacts, and commit the code and config that rebuild them.
How do you version prompts in an LLM application?
Store them as files in the repo (one per file), load them at runtime, review changes in pull requests, run an eval suite in CI on every prompt change, tag releases, and log the commit hash with each request so outputs can be traced to the exact prompt version.
How do you stop secrets from being committed in the first place?
.gitignore for .env files, a committed .env.example, secret scanning in a pre-commit hook (gitleaks or
detect-secrets), GitHub push protection and secret scanning on the server, and CI secrets stored in GitHub
Actions secrets rather than in the code.
Practice¶
- Add a pattern for Google API keys (
AIzafollowed by 35 characters) to the scanner in 6.3 and test it. - Set up pre-commit with Ruff and gitleaks in one of your projects and run it on all files.
- Write a GitHub Actions workflow that runs
pyteston every pull request, and make it a required check.
Back to: Git & GitHub overview · Notes overview