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.

Level 0 lessons
  1. 0.1 · Welcome and expectations

    1. 0.1.1 Systematic, algorithmic and quantitative trading versus discretionary trading
    2. 0.1.2 Realistic expectations: costs, time and risk of loss
    3. 0.1.3 Course structure, entry tests, personal repository and learning with AI
  2. 0.2 · Modern working environment

    1. 0.2.1 Installing Python with uv: versions and support cycle
    2. 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
    3. 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Basic terminal, paths, environment variables and .env files
    5. 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
    6. 0.2.6 Introduction to AI coding assistants: prompts, review and tests
  3. 0.3 · Language fundamentals

    1. 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
    2. 0.3.2 Strings, f-strings and formatting numbers and dates
    3. 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
    4. 0.3.4 Control flow: if, elif, else, for, while and match-case
    5. 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
    6. 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
  4. 0.4 · Intermediate Python

    1. 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
    2. 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
    3. 0.4.3 Iterators, generators, decorators and context managers
    4. 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
    5. 0.4.5 Professional logging and testing with pytest
    6. 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
  5. 0.5 · Numerical computing and data

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
    2. 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
    3. 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
    4. 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
    5. 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
  6. 0.6 · Visualisation

    1. 0.6.1 Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts
    2. 0.6.2 Quick dashboards with Streamlit: a monitoring dashboard in a single script
  7. 0.7 · Minimum mathematics and statistics

    1. 0.7.1 Simple and log returns, compound interest and descriptive statistics
    2. 0.7.2 Probability and distributions, correlation versus causation and simple regression
    3. 0.7.3 Intuitive linear algebra: vectors and matrices as groundwork for portfolios

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 log and git diff tell 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.

git addgit commitgit pushFilesStagingLocal repositoryGitHub
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 main

The 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 commits

A 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:

IN [01]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"))
OUT [01]Git's identifier for a file
8cecfba390d4850d0615764693d0b77c1970e220
b021c7bc604f5e715ece3c14f7ff55f77fa7ee59

The 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:

IN [02].gitignore
# Environment and caches (they are regenerated)
.venv/
__pycache__/
*.py[oc]
.ipynb_checkpoints/

# Secrets: never to Git
.env

# Raw data and heavy outputs
datos/brutos/
*.log

The 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:

IN [03]python
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 False

With it, the typical files of a trading project:

IN [04]python
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)
OUT [04]What goes into Git and what does not
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.log

For 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:

changesapprovedBranch from mainCommitsPull requestReview and testsMerge into main
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 branch

gh 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 main

Undoing 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:

IN [05]python
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")
OUT [05]Looking for secrets in a diff
line 2: possible secret

It 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

  1. Turn your ppt-curso project into a repository with a first commit that includes pyproject.toml, uv.lock, .python-version, README.md, src/ and a .gitignore like the one in the lesson. Check with git status that neither .venv/ nor .env appear.
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.

  1. Extend PATRONES so that the generated HTML reports (informes/*.html) are also left out and check with ignorado that informes/backtest-2026-10.html is left out and informes/plantilla.css is not.
Solution
IN [06]python
patrones = PATRONES + ["informes/*.html"]
print(ignorado("informes/backtest-2026-10.html", patrones))
print(ignorado("informes/plantilla.css", patrones))
OUT [06]Ignoring generated reports
True
False

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

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

  1. Pro Git, chapter "Git Internals: Git Objects" (git-scm.com/book/es/v2, Spanish edition), accessed on October 21, 2026. ↩

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

Each answer is folded under its question

Self-assessment

  1. You have added .env to .gitignore, but git status still shows changes in .env. Why?

    • ABecause .gitignore only works on GitHub
    • BBecause .env was 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 .env was already versioned before you added it to .gitignore: the file ignores what is new, not what Git already tracks

    .gitignore only affects files that Git does not track yet. To stop tracking one that is already versioned: git rm --cached .env and commit. And if it had keys, they are already in the history: they have to be revoked.

  2. 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 work

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

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

  4. 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 .gitignore and 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 .gitignore and not writing keys in the code

    The 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.
  • main must always work: changes are made on a branch and enter through a pull request, with the diff reviewed and the tests green.
  • .gitignore leaves 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.

Resources