---
title: "0.2.5 Git and GitHub: commits, branches and pull requests"
description: "Git and GitHub in a trading project: commits, branches, pull requests, a .gitignore without data or keys and what to do if an API key leaks."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/git-and-github-for-trading/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/git-and-github-for-trading.png"
---

# 0.2.5 · 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.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- 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](https://pythonparatrading.com/en/python-trading-course/level-0/virtual-environments-dependencies-with-uv/), [0.2.4 · Basic terminal, paths, environment variables and .env files](https://pythonparatrading.com/en/python-trading-course/level-0/terminal-paths-environment-variables/)

## By the end of this lesson

- Make commits with useful messages and read a repository's history from the terminal.
- Work on a branch, open it as a pull request on GitHub, review it and merge it into `main`.
- Write a `.gitignore` for a trading project and check which files it leaves out.
- Explain why a key pushed to Git is compromised even if it is deleted, and which steps to follow if it happens.

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

```mermaid
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:

```bash
git config --global user.name "Ana García"
git config --global user.email "ana@example.com"
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):

```bash
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 content[^1]. It can be reproduced in
Python:

```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"))
```
```output
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:

```text title=".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:

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

```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)
```
```output
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`.

<aside class="callout" data-kind="aviso">

`.gitignore` only affects files that Git **does not track yet**. If `.env` went into a commit before you added it to
`.gitignore`, Git will keep seeing it. You have to stop tracking it with `git rm --cached .env` and commit. If it had
keys, moreover, they are in the history: read the last section.

</aside>

## 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
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):

```bash
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:

```bash
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:

```bash
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 secret[^2]. 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:

```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")
```
```output
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.

<aside class="callout" data-kind="riesgo">

If a key reaches a commit that has left your computer, in this order: (1) **revoke it** at the broker or provider and
create another; (2) review the account activity (orders, withdrawals, logins); (3) only then, if you want, clean the
history. Cleaning the history without revoking the key is useless: it may already have been copied. Use paper account
keys while you are learning, without permission to withdraw funds.

</aside>

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

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

```python
patrones = PATRONES + ["informes/*.html"]
print(ignorado("informes/backtest-2026-10.html", patrones))
print(ignorado("informes/plantilla.css", patrones))
```
```output
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.

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

[^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.

## Self-assessment

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

   - A. Because `.gitignore` only works on GitHub
   - B. Because `.env` was already versioned before you added it to `.gitignore`: the file ignores what is new, not what Git already tracks
   - C. Because you have to restart the computer
   - D. Because files that start with a dot cannot be ignored

   *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?

   - A. So that the change is read in full and the tests pass before it enters `main`, the branch that must always work
   - B. Nothing; it only makes sense in a team
   - C. So that GitHub makes a backup
   - D. So that you can make commits

   *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?

   - A. That two files with the same name have the same hash
   - B. That Git encrypts the code
   - C. That an old version cannot be recovered
   - D. That the same content always gives the same identifier and that any change, even of one character, gives another

   *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?

   - A. Yes: GitHub blocks any key
   - B. Yes, if the repository is private
   - 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
   - D. No, because it only works with Python

   *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

### Documentation

- [GitHub Docs: about pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) · docs.github.com
- [GitHub Docs: about push protection](https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection) · docs.github.com
- [Git documentation: gitignore](https://git-scm.com/docs/gitignore) · git-scm.com

### Books

- [Pro Git: recording changes to the repository](https://git-scm.com/book/en/v2/Git-Basics-Recording-Changes-to-the-Repository) · git-scm.com
- [Pro Git: basic branching and merging](https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging) · git-scm.com
- [Pro Git: Git objects](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) · git-scm.com

Previous lesson: [0.2.4 · Basic terminal, paths, environment variables and .env files](https://pythonparatrading.com/en/python-trading-course/level-0/terminal-paths-environment-variables/)

Next lesson: [0.2.6 · Introduction to AI coding assistants: prompts, review and tests](https://pythonparatrading.com/en/python-trading-course/level-0/ai-coding-assistants/)
