Lesson 0.2.5 · 45 min
Git and GitHub: commits, branches, pull requests and .gitignore
Git stores every version of your code and lets you know which version produced each backtest. GitHub keeps it off your computer and adds review before a change reaches the main branch. This lesson covers what you will use every day and the rule that admits no exceptions: keys are never pushed.
- Module
- 0.2 · Modern working environment
- Duration
- 45 min
- Prerequisites
- 0.2.2 · Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles, 0.2.4 · Basic terminal, paths, environment variables and .env files
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
Why version a trading system
Git is a version control system: it stores every state of the project you ask it to, with who made it, when and why. GitHub is a service that hosts Git repositories on the internet and adds tools to review changes. They are different things: Git works without GitHub, on your computer.
For a trading project, Git solves concrete problems:
- Which code produced this result? A backtest with a Sharpe of 1.1 from last month is useless if you do not know which version of the signal it was computed with. With Git, each result can be tied to an exact commit.
- What changed? The system has been losing money since Tuesday.
git logandgit difftell you what was touched and when. - Going back. A bug in the code of the live system is undone in seconds by going back to the previous commit.
- Testing without breaking. A new idea is developed on a branch; the version that trades is not touched until the new one has been tested.
The reference book, Pro Git, is free (the links in "Resources" point to its Spanish translation). This lesson covers the essentials.
Commits: snapshots of the project
Git works in three places. The files in your folder, which you edit; the staging area, where you choose which changes will make up the next commit; and the repository, which stores the commits. With a remote on GitHub, a fourth: the copy on the internet.
Mermaid source
flowchart TB F[Files] -- git add --> P[Staging] P -- git commit --> R[Local repository] R -- git push --> G[GitHub]
The first time, Git needs to know who you are (it is recorded in every commit) and it is a good idea for the main
branch to be called main, as on GitHub:
git config --global user.name "Ana García"
git config --global user.email "[email protected]"
git config --global init.defaultBranch mainThe daily cycle in the course project (the repository was created by uv init in lesson 0.2.2):
git status # what has changed
git diff # how it has changed, line by line
git add src/ppt/senales.py tests/test_senales.py
git commit -m "Shift the signal one day to trade after the close"
git log --oneline -5 # the last five commitsA good commit message says what changes and why, in a line of fewer than 72 characters, in the imperative or the present tense. "Changes" or "fixes" do not help when, months later, you are looking for the commit where the system started losing. And a commit should be small: one logical change (the signal, or the costs, not both). That way one can be undone without losing the other.
How does Git identify each version? With a hash of the content, the same idea as in lesson 0.2.2. For a file, Git
computes the SHA-1 of a header (blob, the size and a null byte) followed by the content1Note 1 · Pro Git, chapter "Git Internals: Git Objects" (git-scm.com/book/es/v2, Spanish edition), accessed on October
21, 2026.. It can be reproduced in
Python:
import hashlib
def hash_git(contenido: bytes) -> str:
cabecera = f"blob {len(contenido)}\0".encode()
return hashlib.sha1(cabecera + contenido).hexdigest()
print(hash_git(b"posicion = senal.shift(1)\n"))
print(hash_git(b"posicion = senal.shift(2)\n"))8cecfba390d4850d0615764693d0b77c1970e220
b021c7bc604f5e715ece3c14f7ff55f77fa7ee59The first value is exactly what git hash-object gives for that content on any machine. One different character (a
two-day lag instead of one) gives an identifier with nothing in common. A commit in turn has a hash computed from
those of its files, the previous commit, the author and the message: that is why the hash of a commit
(git rev-parse --short HEAD, seven characters are enough) unambiguously identifies the code of a backtest. Save it
next to its results, as you saved the environment versions in lesson 0.2.1.
.gitignore: what is not versioned
Not everything in the project folder should go into Git. What stays out is what is secret (.env), what is
regenerated (.venv/, Python caches, outputs) and what weighs a lot or cannot be redistributed (raw data from
a provider). The .gitignore file, at the root, lists patterns:
# Environment and caches (they are regenerated)
.venv/
__pycache__/
*.py[oc]
.ipynb_checkpoints/
# Secrets: never to Git
.env
# Raw data and heavy outputs
datos/brutos/
*.logThe basic rules: # is a comment; * is any text within a name; [oc] is "o" or "c"; a trailing slash (.venv/)
indicates that the pattern is a folder and leaves out everything it contains; a pattern with a slash in the middle
(datos/brutos/) is interpreted from the root of the repository; and a ! in front includes again something another
pattern excluded. The Git documentation has the rest.
Python ships fnmatch in the standard library, which understands the *, ? and [ ] wildcards. It does not
implement the full .gitignore rules, but it is enough for a simplified checker that helps understand how they apply:
from fnmatch import fnmatch
from pathlib import PurePosixPath
PATRONES = [".venv/", "__pycache__/", "*.py[oc]",
".ipynb_checkpoints/", ".env", "datos/brutos/", "*.log"]
def ignorado(ruta: str, patrones: list[str]) -> bool:
p = PurePosixPath(ruta)
carpetas = [c for c in p.parents if str(c) != "."]
for patron in patrones:
if patron.endswith("/"): # folder
patron = patron.rstrip("/")
for c in carpetas:
if fnmatch(str(c), patron) or fnmatch(c.name, patron):
return True
elif fnmatch(p.name, patron) or fnmatch(ruta, patron):
return True
return FalseWith it, the typical files of a trading project:
ficheros = [
"src/ppt/senales.py", "tests/test_senales.py",
"pyproject.toml", "uv.lock", ".env", ".env.example",
"datos/brutos/sint_diario.csv", "datos/procesados/sint.parquet",
".venv/bin/python", "src/ppt/__pycache__/senales.cpython-314.pyc",
"notebooks/.ipynb_checkpoints/explorar-checkpoint.ipynb",
"logs/sistema.log",
]
for f in ficheros:
print("ignored" if ignorado(f, PATRONES) else "to Git ", f)to Git src/ppt/senales.py
to Git tests/test_senales.py
to Git pyproject.toml
to Git uv.lock
ignored .env
to Git .env.example
ignored datos/brutos/sint_diario.csv
to Git datos/procesados/sint.parquet
ignored .venv/bin/python
ignored src/ppt/__pycache__/senales.cpython-314.pyc
ignored notebooks/.ipynb_checkpoints/explorar-checkpoint.ipynb
ignored logs/sistema.logFor these twelve files, the result matches Git's. Two details. .env.example goes in (the .env pattern is exact):
it documents the variables without their values (lesson 0.2.4). And datos/procesados/ goes in with this
.gitignore; if the processed files weigh a lot, add that folder too. For the real check, Git has a command that
tells you which rule ignores a file: git check-ignore -v datos/brutos/sint_diario.csv.
Branches and pull requests
A branch is an independent line of development. main is the version that works: the one you run on paper or
live. To try an idea —adding costs to the signal, changing position sizing— you create a branch, work on it with as
many commits as needed and, when it is ready and tested, merge it into main.
On GitHub, the merge is done with a pull request: a page that shows all the changes of the branch against main,
where they are commented on, the tests are run and, if everything is fine, it is merged with a button. The full
cycle:
Mermaid source
flowchart TB
B[Branch from main] --> C[Commits]
C --> P[Pull request]
P --> R{Review and tests}
R -- changes --> C
R -- approved --> M[Merge into main]In the terminal (with the repository already connected to GitHub; how to do it below):
git switch -c costes-en-senal # new branch from main
# ... edit, test, commits ...
git push -u origin costes-en-senal # push the branch to GitHub
gh pr create --fill # open the PR (or from the web)
# after reviewing and merging on GitHub:
git switch main
git pull # bring in the updated main
git branch -d costes-en-senal # delete the local branchgh is GitHub's official tool for the terminal; everything it does can also be done from the web.
Does a pull request make sense if you work alone? Yes, for two reasons. The first: the PR page shows the complete
diff, everything that changes together, and reading it there catches mistakes that slip past you in the editor (a
debugging print, a forgotten parameter, a file that should not be there). The second: it is the place where the
automated tests run before merging (continuous integration, in level 7). On GitHub you can protect main so that it
accepts no changes except through a pull request.
To connect the local repository to GitHub: create an empty private repository on github.com (without a README, since you already have one) and follow the instructions it shows, which look like this:
gh auth login # once: authenticate
git remote add origin https://github.com/<user>/ppt-curso.git
git push -u origin mainUndoing without fear
Having the history is of little use if you do not know how to go back. Four commands cover almost every case:
git restore src/ppt/senales.py # discard changes not yet committed
git restore --staged .env # take a file out of the staging area
git revert a1b2c3d # new commit that undoes a1b2c3d
git switch --detach a1b2c3d # see the project as it was (read only)git restore acts on what is not yet in a commit; careful, because discarded changes cannot be recovered.
git restore --staged is the lifesaver if you have run git add on something that should not go in (a .env, a data
file) and have not committed yet.
For a commit that is already in main, git revert creates a new commit with the inverse change. It does not delete
anything from the history, so it is safe even if the commit is already on GitHub. It is what you would do if the live
system starts misbehaving after a change: revert that commit, deploy main and analyze the bug calmly on a branch.
git switch --detach takes the project to the state of an old commit to look at it or run it. With the hash saved
next to a backtest and uv sync --locked (lesson 0.2.2), you reproduce that result with the same code and the same
environment. To go back: git switch main.
Never push API keys
The rule already appeared in lessons 0.1.3 and 0.2.4. Here, the technical reason: Git does not forget. If a key
goes into a commit and in the next one you delete the file, the key is still in the previous commit, and anyone with
access to the repository can see it with git log -p. If the repository is public or is ever made public, you have
to assume it has been read.
GitHub helps: push protection is enabled by default on personal accounts and blocks a git push to a public
repository if it detects a known secret2Note 2 · GitHub Docs, "About push protection" (docs.github.com/es/code-security), accessed on October 21, 2026: push
protection for users is enabled by default and prevents pushing recognized secrets to public repositories.. But it only recognizes the patterns on its list of providers; your
broker's key may not be on it. The main defense is still yours. A simple check, which you can run on the diff before
each commit, looks for lines that assign a value to something with a secret-like name:
import re
SECRETO = re.compile(
r"(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[\w-]{8,}",
re.IGNORECASE,
)
diff = """\
+def conectar():
+ API_KEY = "pk_live_93ab71c0d2e8"
+ cliente = Broker(api_key=os.environ["BROKER_API_KEY"])
+ return cliente
"""
for n, linea in enumerate(diff.splitlines(), start=1):
if SECRETO.search(linea):
print(f"line {n}: possible secret")line 2: possible secretIt flags line 2, the key written in the code, and not line 3, which reads it from the environment as it should. It is a crude filter (it will give false positives and miss unusual formats) and it does not replace the rule; in level 7 it is automated with dedicated tools that run before each commit.
Exercises
- Turn your
ppt-cursoproject into a repository with a first commit that includespyproject.toml,uv.lock,.python-version,README.md,src/and a.gitignorelike the one in the lesson. Check withgit statusthat neither.venv/nor.envappear.
Solution
If uv init already created the repository, git add . and git commit -m "Initial course structure" after
completing the .gitignore are enough. Before the commit, git status should list only the files you want to
version; if .env or .venv/ appears, the .gitignore is not at the root or has a typo. git check-ignore -v .env
should respond with the .gitignore line that excludes it.
- Extend
PATRONESso that the generated HTML reports (informes/*.html) are also left out and check withignoradothatinformes/backtest-2026-10.htmlis left out andinformes/plantilla.cssis not.
Solution
patrones = PATRONES + ["informes/*.html"]
print(ignorado("informes/backtest-2026-10.html", patrones))
print(ignorado("informes/plantilla.css", patrones))True
FalseThe pattern has a slash in the middle, so in Git it applies from the root: informes/*.html does not ignore
src/informes/x.html. If the reports can be regenerated from the code and the data, there is no need to version
them.
- Create a branch
costes-en-senal, make any commit on it, push it to GitHub and open a pull request. Before merging it, read the diff in the "Files changed" tab. Which three things do you check before pressing "Merge"?
Solution
A reasonable list: that the diff contains only what you meant to change (no data file, debugging print or secret);
that the tests pass (on your machine with uv run pytest and, once you configure it, in the PR itself); and that the
PR message and description explain what changes and why, so that six months from now you know why the system started
charging costs. After merging: git switch main and git pull.
Footnotes
-
Pro Git, chapter "Git Internals: Git Objects" (git-scm.com/book/es/v2, Spanish edition), accessed on October 21, 2026. ↩
-
GitHub Docs, "About push protection" (docs.github.com/es/code-security), accessed on October 21, 2026: push protection for users is enabled by default and prevents pushing recognized secrets to public repositories. ↩
Each answer is folded under its question
Self-assessment
You have added
.envto.gitignore, butgit statusstill shows changes in.env. Why?- ABecause
.gitignoreonly works on GitHub - BBecause
.envwas already versioned before you added it to.gitignore: the file ignores what is new, not what Git already tracks - CBecause you have to restart the computer
- DBecause files that start with a dot cannot be ignored
Show answer
Answer B. Because
.envwas already versioned before you added it to.gitignore: the file ignores what is new, not what Git already tracks.gitignoreonly affects files that Git does not track yet. To stop tracking one that is already versioned:git rm --cached .envand commit. And if it had keys, they are already in the history: they have to be revoked.- ABecause
What is a pull request for if you work alone in your repository?
- ASo that the change is read in full and the tests pass before it enters
main, the branch that must always work - BNothing; it only makes sense in a team
- CSo that GitHub makes a backup
- DSo that you can make commits
Show answer
Answer A. So that the change is read in full and the tests pass before it enters
main, the branch that must always workThe pull request is the checkpoint between an idea and the branch your system uses. Seeing the complete diff and the tests green before merging catches mistakes that slip past you in the editor.
- ASo that the change is read in full and the tests pass before it enters
Git identifies each stored file with a SHA-1 hash of its content. What does that imply?
- AThat two files with the same name have the same hash
- BThat Git encrypts the code
- CThat an old version cannot be recovered
- DThat the same content always gives the same identifier and that any change, even of one character, gives another
Show answer
Answer D. That the same content always gives the same identifier and that any change, even of one character, gives another
The identifier comes from the content, not from the name or the date. That is why the hash of a commit identifies exactly the code that produced a result.
GitHub has push protection for secrets enabled by default for public repositories. Is that enough?
- AYes: GitHub blocks any key
- BYes, if the repository is private
- CNo: it only recognizes the secret types on its list, and your broker's key may not be on it; the defense is
.gitignoreand not writing keys in the code - DNo, because it only works with Python
Show answer
Answer C. No: it only recognizes the secret types on its list, and your broker's key may not be on it; the defense is
.gitignoreand not writing keys in the codeThe protection looks for patterns from known providers. It is a useful safety net, not a substitute for keeping keys out of the repository.
Key points
- A commit is a snapshot of the project with an identifier derived from its content. Save the commit hash next to each backtest result.
- Daily cycle:
git status,git add,git commit,git push. Small commits with messages that say what changes and why. mainmust always work: changes are made on a branch and enter through a pull request, with the diff reviewed and the tests green..gitignoreleaves out.venv/,.env, raw data, caches and logs. It only affects what Git does not track yet.- A key that has reached a commit is compromised: it is revoked and another is created. Deleting it afterwards does not remove it from the history.