Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Before Python, Docker, or tests, you need a repo that survives a fresh clone. This baseline includes safe Git defaults, one reproducible eval command, shell checks that tell you what machine and dataset you're using, and Linux habits that keep long jobs alive.
One tiny access-request eval file runs through the whole lesson. Another machine should be able to clone the repo, run one command, and get the same 0.667 result instead of "it worked on my laptop." Git's snapshot model is built so a clean clone gets the same tracked files and history.[1] Identical eval behavior still needs an explicit environment contract (activation script, pinned deps, and later a container), not the Git tree alone.

Start with the smallest useful repo
Create a new directory and initialize Git as you would for any real AI project.
1mkdir access-rag && cd access-rag
2git initThe .git directory is the repository's memory. Everything that follows will be tracked or explicitly ignored.
The .gitignore that protects AI work
Create .gitignore with the patterns that real LLM projects need:
1# Python
2__pycache__/
3*.py[cod]
4*$py.class
5.venv/
6env/
7ENV/
8
9# Environment & secrets (do not commit these)
10.env
11.env.local
12*.pem
13secrets/
14
15# Generated model and vector artifacts that do not travel with the repo
16checkpoints/
17models/local/
18*.pt
19*.pth
20chroma/
21faiss_index/
22*.db
23*.sqlite3
24
25# OS and editor noise
26.DS_Store
27.idea/
28.vscode/
29*.swp
30
31# Evaluation caches that should be regenerated
32eval_cache/
33runs/
34wandb/
35mlruns/
36
37# Do not ignore *.gguf, *.safetensors, or *.bin here.
38# .gitattributes routes those extensions through Git LFS when configured.Track large files with Git LFS (Large File Storage) so the repo stays small while model weights travel with the project when needed. GitHub warns on files over 50 MiB and hard-blocks any single file over 100 MiB; LFS replaces the file in history with a small pointer and stores the bytes separately.[2] A path can't be both ignored and LFS-tracked, so the LFS-managed extensions stay out of .gitignore; generated checkpoints and local model directories remain ignored. Git LFS is a separate tool, so check for it first. If it isn't installed yet, don't add those model files. Without git lfs pull (or with GIT_LFS_SKIP_SMUDGE=1), clones keep tiny pointer files that look like valid paths until a weight load fails. Production datasets often outgrow LFS quotas; then use object-store manifests or tools such as DVC, and keep only the pointer/manifest in Git.
1cat > .gitattributes << 'EOF'
2# Install Git LFS before committing model weights:
3# git lfs track "*.gguf" "*.safetensors" "*.bin"
4EOF
5
6if command -v git-lfs >/dev/null 2>&1; then
7 git lfs install
8 git lfs track "*.gguf" "*.safetensors" "*.bin"
9else
10 echo "Git LFS is not installed. Safe for now: do not commit model weights yet."
11fi
12
13git add .gitattributesCommit the skeleton.
1git add .gitignore .gitattributes
2git commit -m "chore: initial AI project skeleton with safe .gitignore and LFS"This repo can be cloned without leaking keys or placing a 7 GB model blob in ordinary Git history. When LFS-managed weights exist, document whether setup should fetch them immediately or only for workflows that need them.
The eval that must travel with the code
Place the three-row access-request evaluation file that the rest of the curriculum will reuse.
1mkdir -p eval
2cat > eval/access_requests.jsonl << 'EOF'
3{"prompt": "Access request 101 status?", "expected": "approved", "prediction": "approved"}
4{"prompt": "Access request 102 status?", "expected": "blocked", "prediction": "escalated"}
5{"prompt": "Access request 103 status?", "expected": "restored", "prediction": "restored"}
6EOFThis tiny file is the contract. The same three rows also live in assets/access_requests.jsonl so the runnable scorer below can open a real fixture without depending on your laptop path.
Add the provided scorer
The shell gate needs a small program that reads JSONL rows and computes exact-match accuracy. Don't treat this Git-and-shell chapter as a Python prerequisite: copy the standard-library scorer as a provided utility and focus on its command-line contract. The later Python lesson explains functions, exceptions, tests, and CI before asking you to extend it.

The utility has one job: validate the three required fields, print the exact-match receipt, and exit nonzero when the fixture is missing, malformed, empty, or below the 2/3 gate. Save it as scripts/score_access_requests.py; scripts/run_eval.sh will invoke it on every clone.
1import json
2import sys
3from pathlib import Path
4
5REQUIRED = ("prompt", "expected", "prediction")
6EVAL_FILE = Path("eval/access_requests.jsonl")
7ASSET_FILE = Path("assets/access_requests.jsonl")
8
9path = EVAL_FILE if EVAL_FILE.exists() else ASSET_FILE
10if not path.exists():
11 print(f"ERROR: {path} missing; commit the eval fixture", file=sys.stderr)
12 raise SystemExit(1)
13
14rows = []
15for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
16 if not line.strip():
17 continue
18 try:
19 row = json.loads(line)
20 except json.JSONDecodeError as error:
21 print(f"ERROR: line {line_number}: {error.msg}", file=sys.stderr)
22 raise SystemExit(1)
23 missing = [field for field in REQUIRED if field not in row]
24 if missing:
25 print(f"ERROR: line {line_number} missing {missing}", file=sys.stderr)
26 raise SystemExit(1)
27 rows.append(row)
28
29if not rows:
30 print("ERROR: eval file is empty", file=sys.stderr)
31 raise SystemExit(1)
32
33def normalize(label: str) -> str:
34 return str(label).strip().lower()
35
36correct = sum(normalize(row["expected"]) == normalize(row["prediction"]) for row in rows)
37total = len(rows)
38score = correct / total
39print(f"Eval rows: {total}")
40print(f"Exact-match accuracy on tiny fixture: {score:.3f} ({correct}/{total})")
41# Count gate: fail below 2/3 correct (same rule Docker and Python reuse).
42if correct * 3 < total * 2:
43 print("Gate failed: score regressed below 2/3", file=sys.stderr)
44 raise SystemExit(1)
45print("Gate passed. You may commit.")1Eval rows: 3
2Exact-match accuracy on tiny fixture: 0.667 (2/3)
3Gate passed. You may commit.Normalization is part of the shared contract: labels are compared after strip().lower(), and the gate uses counts (correct * 3 < total * 2), not a float equality check against 0.667. Success prints 3, 0.667, and exits zero; bad input or a regression exits nonzero.
Why does the gate compare integer counts instead of testing whether a floating-point score equals 0.667?
Answer
2 / 3 is not exactly 0.667. The count rule correct * 3 >= total * 2 expresses the threshold without rounding ambiguity.
The pre-commit gate that protects the score
Create a tiny executable that the pre-commit hook and clean-clone reproduction command will run.
1mkdir -p scripts
2cat > scripts/run_eval.sh << 'EOF'
3#!/usr/bin/env bash
4set -euo pipefail
5
6EVAL_FILE="eval/access_requests.jsonl"
7if [[ ! -f "$EVAL_FILE" ]]; then
8 echo "ERROR: $EVAL_FILE missing. Did you forget to commit the fixture or pull the latest repo?"
9 exit 1
10fi
11
12PYTHON_BIN="${PYTHON_BIN:-python3}"
13if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
14 echo "ERROR: Python 3 is required by the provided scorer. Install it or set PYTHON_BIN."
15 exit 1
16fi
17
18if [[ -f scripts/score_access_requests.py ]]; then
19 "$PYTHON_BIN" scripts/score_access_requests.py
20else
21 echo "ERROR: scripts/score_access_requests.py missing. Add the Python scorer from this chapter."
22 exit 1
23fi
24EOF
25chmod +x scripts/run_eval.shCreate a repo-local reproduction command. This is important: shell aliases and Git hooks are local machine state, but repro.sh travels with the repo.
1cat > repro.sh << 'EOF'
2#!/usr/bin/env bash
3set -euo pipefail
4
5./scripts/run_eval.sh
6EOF
7chmod +x repro.shNow wire the same gate as a pre-commit hook. Don't try to commit .git/hooks/pre-commit; files under .git/ are Git internals, not normal tracked project files. Commit the hook source under scripts/, then install it into .git/hooks/ on each clone.
1cat > scripts/pre-commit-ai-eval.sh << 'EOF'
2#!/usr/bin/env bash
3set -euo pipefail
4
5echo "Running AI eval gate before commit..."
6./scripts/run_eval.sh
7echo "Eval gate passed."
8EOF
9
10cat > scripts/install_hooks.sh << 'EOF'
11#!/usr/bin/env bash
12set -euo pipefail
13
14mkdir -p .git/hooks
15cp scripts/pre-commit-ai-eval.sh .git/hooks/pre-commit
16chmod +x .git/hooks/pre-commit
17echo "Installed .git/hooks/pre-commit"
18EOF
19
20chmod +x scripts/pre-commit-ai-eval.sh scripts/install_hooks.sh
21./scripts/install_hooks.shTest it.
1./repro.sh
2git add scripts/score_access_requests.py scripts/run_eval.sh scripts/pre-commit-ai-eval.sh scripts/install_hooks.sh repro.sh eval/access_requests.jsonl
3git commit -m "feat: add three-row eval and pre-commit gate that protects 0.667"1Eval rows: 3
2Exact-match accuracy on tiny fixture: 0.667 (2/3)
3Gate passed. You may commit.
4Running AI eval gate before commit...
5Eval rows: 3
6Exact-match accuracy on tiny fixture: 0.667 (2/3)
7Gate passed. You may commit.
8Eval gate passed.If the scorer reports a regression or the file disappears, the commit is rejected before the broken state enters history.
Shell one-liners that make the invisible visible
Add a few reusable functions to ~/.zshrc or ~/.bashrc.
For NVIDIA machines, nvidia-smi --query-gpu with --format=csv is the scriptable query interface you'll want in shell helpers.[3]
1# GPU snapshot (works on NVIDIA, falls back gracefully)
2gpu() {
3 if command -v nvidia-smi >/dev/null 2>&1; then
4 nvidia-smi --query-gpu=index,name,memory.used,memory.total,utilization.gpu --format=csv,noheader
5 else
6 echo "No NVIDIA GPU or nvidia-smi not in PATH"
7 fi
8}
9
10# Dataset size at a glance
11ds() {
12 du -sh "${1:-.}" 2>/dev/null | awk '{print $1 " " $2}'
13 echo "JSONL rows: $(find "${1:-.}" -name '*.jsonl' -exec wc -l {} + 2>/dev/null | tail -1 | awk '{print $1}')"
14}
15
16# One-command reproduction of the current eval
17repro() {
18 if [[ -x ./repro.sh ]]; then
19 ./repro.sh
20 elif [[ -x ./scripts/run_eval.sh ]]; then
21 ./scripts/run_eval.sh
22 elif [[ -x ./reproduce.sh ]]; then
23 ./reproduce.sh
24 else
25 echo "No reproducible entrypoint found (looked for repro.sh, scripts/run_eval.sh, or reproduce.sh)"
26 return 1
27 fi
28}After sourcing, gpu, ds, and repro become muscle memory. You type one word and immediately know whether the machine has the resources the workload expects. In a clean clone, use ./repro.sh; aliases should make the common path faster, not hide the real entry point.
Inspect data and processes without crashing the box
Two shell checks cover common remote-machine failures: inspecting a dataset that's too large to open and finding a process that still holds GPU memory. For the first, never run cat train.jsonl on a multi-gigabyte file. It floods the terminal and stalls a remote host. Stream the file instead so each tool reads a little, passes it on, and keeps memory use flat.
1head -n 1 eval/access_requests.jsonl # peek at the schema of one row
2wc -l eval/access_requests.jsonl # count rows without loading the file
3grep -c '"expected"' eval/access_requests.jsonl # how many rows have the fieldThe pipe | chains these into one pass. grep '"restored"' eval/access_requests.jsonl | wc -l filters, then counts, without ever holding the whole file in memory.
The second reflex is reclaiming a GPU from a hung or detached job. A PyTorch process can remain alive after its controlling shell exits, and nvidia-smi reports currently active compute processes.[3] When the process table shows a PID, inspect it, ask it to exit cleanly, and only force-kill if it refuses.
1nvidia-smi # read the PID in the bottom "Processes" table
2kill 12345 # SIGTERM (15): let the process flush and release VRAM
3for _ in {1..10}; do # give graceful shutdown up to ten seconds
4 kill -0 12345 2>/dev/null || break
5 sleep 1
6done
7kill -0 12345 2>/dev/null && kill -9 12345 # escalate only if it is still aliveIf VRAM remains allocated but nvidia-smi shows no owning compute PID, there's no process ID to kill. Check container or PID-namespace visibility and other driver clients, then escalate to container-runtime or driver diagnosis instead of signaling a nonexistent process.
Start with SIGTERM. Signal 15 lets the process clean up, close files, and release CUDA memory. SIGKILL (signal 9) can't be caught or handled and risks leaving lock files or corrupt checkpoints behind, so reserve it for a process that refuses to exit.[4]
You find a live training PID holding GPU memory. Which signal should you send first, and when should you escalate?
Answer
Send SIGTERM first so the process can flush files and release resources. Wait for a bounded grace period, then use SIGKILL only if the same PID remains alive.
Linux fundamentals that keep long jobs alive
Training and evaluation jobs can run for hours, often through an SSH session. These commands keep the job alive and make its resource use visible:
- Detach a process that survives logout:
nohup python train.py > train.log 2>&1 & - Manage sessions across SSH disconnects:
tmux new -s training,tmux attach -t training,Ctrl-b dto detach. - Give the job lower priority so your laptop remains usable:
nice -n 10 python train.py - Find the files using disk right now:
du -ah /workspace | sort -rh | head -20 - Check which Python process is using the GPU:
nvidia-smi+ps aux | grep python
Together, these commands keep a job alive across disconnects, lower its CPU priority when needed, and show which files or processes are consuming resources.
The reproducible activation contract
Create an activation script that each teammate and each CI job can source. This first chapter doesn't require PyTorch yet, so the script reports CUDA when torch is already installed.
1cat > requirements.txt << 'EOF'
2# Empty in this first chapter.
3# Later chapters will add pinned runtime packages here.
4EOF
5
6cat > .env.example << 'EOF'
7# Copy to .env locally. Never commit .env.
8ACCESS_API_KEY=replace-me
9HF_HOME=
10EOF
11
12cat > activate.sh << 'EOF'
13#!/usr/bin/env bash
14
15_activate_fail() {
16 echo "ERROR: $1" >&2
17}
18
19_activate_main() {
20 # Prefer an explicit interpreter so clones don't silently use 3.10 vs 3.12.
21 # Optional: pin with a `.python-version` file and tools like uv or pyenv.
22 PYTHON_BIN="${PYTHON_BIN:-python3}"
23 if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
24 _activate_fail "install python3 or set PYTHON_BIN=/path/to/python"
25 return 1
26 fi
27
28 # 1. Create or reuse a local virtualenv
29 if [[ ! -d .venv ]]; then
30 "$PYTHON_BIN" -m venv .venv || {
31 _activate_fail "could not create .venv"
32 return 1
33 }
34 fi
35 source .venv/bin/activate || {
36 _activate_fail "could not activate .venv"
37 return 1
38 }
39
40 # 2. Install declared dependencies only when requirements.txt has entries.
41 # Prefer a locked install once you add packages: `uv lock` + `uv sync`,
42 # or `pip-compile` + `pip install -r requirements.txt --require-hashes`.
43 grep -Ev '^\s*(#|$)' requirements.txt >/dev/null 2>&1
44 _requirements_status=$?
45 if [[ $_requirements_status -eq 0 ]]; then
46 python -m pip install -r requirements.txt || {
47 _activate_fail "could not install requirements.txt"
48 return 1
49 }
50 elif [[ $_requirements_status -gt 1 ]]; then
51 _activate_fail "could not read requirements.txt"
52 return 1
53 fi
54 unset _requirements_status
55
56 # 3. Print the local environment without requiring GPU packages yet
57 if ! python - << 'PY'
58import os, sys
59print("Python:", sys.version.split()[0])
60if sys.version_info[:2] < (3, 12):
61 print("WARNING: this project targets Python 3.12+; set PYTHON_BIN to a 3.12 interpreter")
62visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
63print(
64 "CUDA_VISIBLE_DEVICES:",
65 visible_devices if visible_devices is not None else "<unset; no mask applied>",
66)
67try:
68 import torch
69except ModuleNotFoundError:
70 print("PyTorch: not installed yet (OK for this chapter)")
71else:
72 print("PyTorch:", torch.__version__)
73 print("CUDA available:", torch.cuda.is_available())
74 if torch.cuda.is_available():
75 print("GPU:", torch.cuda.get_device_name(0))
76print("HF_HOME:", os.environ.get("HF_HOME", "(default ~/.cache/huggingface)"))
77PY
78 then
79 _activate_fail "environment probe failed"
80 return 1
81 fi
82
83 echo "Environment ready. Run './repro.sh' to execute the eval gate."
84}
85
86_activate_main
87_activate_status=$?
88unset -f _activate_main _activate_fail
89if [[ $_activate_status -ne 0 ]]; then
90 return "$_activate_status" 2>/dev/null || exit "$_activate_status"
91fi
92unset _activate_status
93EOF
94chmod +x activate.shThe outer status check is essential for a sourced script. A helper that merely return 1s can fail while the rest of activate.sh continues and its final echo makes the source command look successful. Here every setup failure returns from _activate_main, and the file-level return propagates that nonzero status to source activate.sh without closing an interactive shell.
Document it in README.md:
1## Quick start
2
3git clone [email protected]:your-org/access-rag.git
4cd access-rag
5./scripts/install_hooks.sh
6if command -v git-lfs >/dev/null 2>&1; then
7 git lfs pull
8fi
9source activate.sh && ./repro.shNow a fresh engineer (or a fresh GPU box provisioned by your platform team) can reproduce the same 0.667 result through the tracked setup path.

The failure modes you'll see in real life
| Symptom | Most common cause | Fix that belongs in the repo |
|---|---|---|
CUDA not found on the GPU box | Host driver or container runtime is missing, the image lacks a compatible CUDA stack, or CUDA_VISIBLE_DEVICES is explicitly empty/invalid and hides the GPU | Compare nvidia-smi, the printed CUDA_VISIBLE_DEVICES, and torch.cuda.is_available(); remove an accidental mask or choose a valid device index, then document the required image and runtime. An unset variable normally exposes all available devices. |
ModuleNotFoundError for a package that worked on the laptop | requirements.txt is incomplete or uses unpinned versions | Rebuild from a clean env, pin direct deps (and prefer a lockfile such as uv.lock or hashed requirements.txt), commit the pins, and reinstall from that file |
| Eval returns 0.000 because the three-row JSONL is missing | .gitignore did not protect the generated cache directory that the author had on disk | Move the fixture to eval/ and add the directory to the committed tree; don't rely on "I had it in my downloads folder" |
| Pre-commit hook doesn't run on a fresh clone | Hooks under .git/hooks/ are local machine files, not tracked project files | Commit scripts/pre-commit-ai-eval.sh and scripts/install_hooks.sh, then run the installer after cloning |
| Pre-commit hook fails with "permission denied" | The hook script was installed without chmod +x or the clone was on a filesystem that strips execute bits | chmod +x scripts/*.sh .git/hooks/* + a one-line check in the installer |
"It worked yesterday" after a git pull | teammate committed a new large model without LFS or changed the expected schema of the eval file | LFS tracking + a schema validation step in the scorer + git diff before each git pull on data files |
Encode each diagnosis and prevention in the repo so the next clone fails clearly instead of requiring machine-specific guesswork.
The repo contract
The first real engineering loop is in place:
git clone+source activate.shproduces a working environment on any machine with the declared dependencies.repro(or the pre-commit hook) guarantees that the tiny contract (three rows, 0.667) is still satisfied after each change.gpu,ds, and the Linux session commands let you see what the hardware is doing.- The
.gitignore+ LFS rules + activation script travel with the code, so the next person doesn't have to reverse-engineer your laptop.
Self check: clone the repo into a fresh temporary directory and run ./scripts/install_hooks.sh && source activate.sh && ./repro.sh. The expected output is the same 0.667 score plus a visible environment summary. If the command needs a hidden laptop file, name that missing contract, add it to .env.example, README, LFS, or the activation script, and rerun the clean-clone check.
Why is a clean temporary clone stronger evidence than rerunning repro in your working directory?
Answer
It removes untracked files, shell aliases, installed hooks, and other laptop state. If the clean clone passes, the required fixture, installer, environment contract, and command traveled through Git.
Reproducible change checklist
Before sharing a commit, inspect git status, run the repo-local eval, clone into a temporary directory, install tracked hooks, source activate.sh, and run ./repro.sh. Any dependency on an untracked file becomes a named repo contract before handoff.