Skip to content

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

.gitignore
# 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:

  1. 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.
  2. Remove it from the code: load it from the environment instead, and add the file to .gitignore.
  3. If it was pushed, remove it from history too, because deleting the file in a new commit leaves the key in every old commit:

    pip install git-filter-repo
    git filter-repo --invert-paths --path .env                  # drop a file from every commit
    git filter-repo --replace-text <(echo "sk-live-abc123==>REDACTED")   # or scrub one string everywhere
    git push --force-with-lease --all                           # rewritten history: everyone must re-clone
    
  4. 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")
Output
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:

.pre-commit-config.yaml
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:

.github/workflows/ci.yml
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 test and evals jobs 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 (AIza followed 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 pytest on every pull request, and make it a required check.

Back to: Git & GitHub overview · Notes overview