Git, branches and Pull Requests — how teams work
From git init to push and pull, keeping .venv and .env out with .gitignore, small feature branches, clean commit messages and Pull Requests — plus a Python tool that reads git log to check commits.
How do you want to learn?
The problem we are solving
An AI engineer has spent a week on a ticket-triage project: changing the prompt, growing the keyword list, rewriting one function. On Friday the results are worse than on Monday. Which change is to blame? The folder holds triage.py, triage_v2.py, triage_final.py and triage_final_REAL.py — and nobody knows what changed in which.
Meanwhile a colleague editing the same file in a shared folder has wiped out their work. And a month ago someone pasted an API key straight into the code for a quick test, and through that folder it has reached ten more people.
All three problems have one answer, and almost every software team in the world uses it: keep history with Git, do each piece of work on its own branch, and before anything reaches the main code, have someone else review it in a Pull Request. This chapter teaches that whole cycle — and ends by writing a Python tool that automates one of the reviewer's jobs.
By the end of this chapter you can
- Keep and share a project's history with
git init,add,commit,pushandpull - Keep
.venvand.envout of the repository with.gitignore, and know what to do when one is committed by mistake - Work on small, short-lived feature branches and open a clean Pull Request
- Write commit messages the way reviewers read them
- Write a Python tool that reads
git logthroughsubprocessand checks commit messages
Prerequisites: The AI engineer track, and dev environments with uv — especially uv init and uv run.
First-time setup
Git records who made every commit. So the first time, you introduce yourself once, and it is worth telling Git to name the main branch of new repositories main, because that is what most teams use now:
git config --global user.name "Nadia Karim"
git config --global user.email "[email protected]"
git config --global init.defaultBranch mainUse your own name and the email your code-hosting account is registered with. Without the last line, older Git versions name the main branch master — which works, but will not match the rest of the team.
Three places: the folder, staging and history
In the previous chapter uv init created a Git repository by itself. This chapter's example project starts the same way:
uv init ticket-triage --python 3.12
cd ticket-triageGit thinks in three places, and every command moves something from one to another:
- The working tree — your folder, where you edit files
- The staging area — the list of what goes into the next commit;
git addputs things here - History — the chain of commits;
git committurns the staging area into a permanent commit
Why a separate staging step? Because in the middle of a task you may have changed five files, two of which fix a bug and three of which add a feature. Staging lets you split them into two separate, meaningful commits — which is worth a lot to a reviewer.
The command for seeing where things stand is git status, or in short form git status --short.
.gitignore: what never enters the repository
AI projects nearly always have a .env file holding API keys. Say the project gets a file like this (sk-... here is only a placeholder, not a real key):
LLM_API_KEY=sk-...The .gitignore that uv init creates contains .venv but not .env. So the first job is to add it at the end:
# Secrets
.envNow run uv run main.py once (so .venv and uv.lock get created) and check the state:
git status --short
?? .gitignore
?? .python-version
?? README.md
?? main.py
?? pyproject.toml
?? uv.lock?? means Git sees the file but is not tracking it yet. The real news is what is not in the list: no .venv, no .env. Both are in the folder, but Git is pretending not to see them. (.venv actually has two layers of protection: when uv creates the environment, it also drops a tiny .gitignore containing just * inside it.) And uv.lock is there — it should be committed, because it is how colleagues get exactly the same environment.
So how will colleagues know which keys they need? The usual way is to commit a .env.example file containing the names but not the values. A newcomer copies it to .env and fills in their own keys.
A secret that has been committed is not removed by deleting it in the next commit. Git's whole job is to remember every earlier state, so the key stays in the earlier commit forever — anyone can pull it out withgit show. Once a key has been pushed to any remote, the only safe move is to go to the service the key belongs to, revoke it, and create a new one. That is why.envneeds to be in.gitignorebefore the file is ever created.
The first commit, and good commit messages
git add .
git commit -m "chore: scaffold project with uv"
[main (root-commit) 0f78e59] chore: scaffold project with uv
6 files changed, 35 insertions(+)
create mode 100644 .gitignore
create mode 100644 .python-version
create mode 100644 README.md
create mode 100644 main.py
create mode 100644 pyproject.toml
create mode 100644 uv.lock0f78e59 is the commit's identity (the start of its hash) — it will be different on your machine, because the time and the author are mixed into it.
A commit message is written for a future reader — you in six months, and today's reviewer. Many teams follow a common style called Conventional Commits:
type(scope): short summary in the imperative- type says what kind of change it is:
feat(a new capability),fix(a bug fix),docs,refactor,test,chore(maintenance) - scope is optional, in parentheses, naming which part of the project:
feat(triage): ... - summary is in the imperative ("add", "fix", "remove" — not "added"), within 72 characters, with no full stop at the end
Side by side the difference is obvious:
wip
fixed stuff
Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
fix(triage): lowercase ticket text before keyword matchingThe first three raise no error — Git accepts any message. But in git log the first two say nothing, and the third is so long it gets cut off in a one-line listing. Reading the last one, a reviewer knows what changed and where. If the "why" needs more room, it goes in the message body, after a blank line.
Branches: every piece of work in its own room
The main branch is the team's most valuable thing: whatever is there works and can be deployed at any time. So nobody works directly on main. Every task gets a new branch:
git switch -c feat/priority-score
Switched to a new branch 'feat/priority-score'Here triage.py is written:
def priority(text: str) -> str:
urgent_words = ("outage", "down", "breach")
if any(word in text for word in urgent_words):
return "high"
return "normal"git add triage.py
git commit -m "feat(triage): add keyword-based priority"
[feat/priority-score ded4456] feat(triage): add keyword-based priority
1 file changed, 5 insertions(+)
create mode 100644 triage.pyBranch names follow the same style: feat/..., fix/..., docs/... — the list tells you at a glance what each one is.
A word on branching strategy. At one time many teams kept several long-lived branches — develop, release, hotfix — and moved code between them under elaborate rules. Most teams now take the simpler path: one main, and small, short-lived feature branches off it that get reviewed and merged back within a day or two. The longer a branch stays apart, the further main moves on, and the bigger the conflicts when it comes back. This course follows the simple path.
Remotes: push and pull
So far everything is on your machine. Sharing with a team needs a remote repository — usually on GitHub or a similar service. Creating a repository there gives you an address, and you write:
git remote add origin https://github.com/<your-account>/ticket-triage.gitThe transcripts in this chapter were verified without any online service: a "bare" repository was created in a neighbouring folder and used as the remote. To Git the two are the same thing, and you can practise on your own machine this way too:
git init --bare ../remote.git
git remote add origin ../remote.gitThe first push needs -u to say which remote branch this branch is linked to:
git push -u origin main
branch 'main' set up to track 'origin/main'.
To ../remote.git
* [new branch] main -> mainThe feature branch goes the same way:
git push -u origin feat/priority-score
branch 'feat/priority-score' set up to track 'origin/feat/priority-score'.
To ../remote.git
* [new branch] feat/priority-score -> feat/priority-scoreNow a colleague clones the repository:
git clone remote.git teammate
Cloning into 'teammate'...
done.Their folder has README.md, main.py, pyproject.toml, uv.lock (and the hidden files) — but no .venv and no .env. Exactly as it should be: they run uv sync to build their own environment, and create their own .env from .env.example.
After the feature is merged into main (in real work that happens when the Pull Request is merged — see below), the colleague fetches the new state:
git pull
Updating 0f78e59..5f802b4
Fast-forward
triage.py | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 triage.py(The real output starts with a From ... line giving the remote's full path; it is left out here.) Fast-forward means the colleague had no new commits of their own, so Git simply moved their main forward.
Issues and Pull Requests
At a company, work usually starts with an issue — a numbered description of a problem, written on the code-hosting service. For example: #12 — Uppercase keywords are not detected (tickets saying "OUTAGE" come through as normal).
Then the cycle goes like this:
- A branch off
main:git switch -c fix/uppercase-keywords - Small, meaningful commits
- Push the branch
- On the service's website, or from the command line, open a Pull Request (PR) — a request saying "take my branch's changes into
main" - The reviewer comments, and you answer with new commits on the same branch
- Once approved and the automated checks (CI) pass, the PR is merged and the branch deleted
With GitHub's command-line tool gh, step 4 looks like this (its output depends on your account and repository, so it is not shown here):
gh pr create --base main --title "fix(triage): detect uppercase keywords" --body-file PR.mdA clean PR description (PR.md) answers the reviewer's three questions — what, why, and how it was tested:
## What
Lowercase ticket text before matching urgent keywords.
## Why
Tickets written in capitals ("OUTAGE") were scored as normal.
Closes #12
## How I tested
priority("Service DOWN") returned "normal" on main, "high" on this branch
uv run check_commits.py -> 0 of 2 commit messages need workThe Closes #12 line is a small convenience: when the PR is merged into the default branch, the service closes issue #12 by itself.
How mentors review
Every hands-on task in this course will be looked at through a reviewer's eyes, and at a company these are the questions that come up:
- Is the PR small and single-purpose? One feature, one bug, or one refactor — not all three together. Beyond a few hundred lines, review quality drops, because people cannot hold their attention across a long diff.
- Do the commit messages read as a history? A history full of
wipandfixis of no use to anyone. - What got in that should not have?
.venv,.env,__pycache__, large data files — if any of these appear in the diff, the PR goes back. - What should be there? If a new package was added, the changes to
pyproject.tomlanduv.lockmust be in the diff. - Does the description say how it was tested?
If the last commit's message is wrong, you can change it before pushing:
git commit --amend -m "docs: describe the project in README"
git log --oneline -1
a18cc1a docs: describe the project in READMEChanging a commit after pushing means rewriting history, which causes trouble for colleagues; in that case a new commit is better. Many teams merge PRs with "squash" — all the branch's commits are combined into one, and the PR title becomes its message. Then the PR title needs to follow the same style too.
A complete example
One of the reviewer's jobs can be handed to a machine: checking that every commit message on the branch follows the rules. Teams run checks like this in CI, so the result appears as soon as a PR is opened. The check_commits.py below does exactly that — it runs git log and reads only the current branch's commits, the ones not on main:
import re
import subprocess
import sys
ALLOWED_TYPES = {"feat", "fix", "docs", "refactor", "test", "chore"}
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(\([a-z0-9-]+\))?: (?P<summary>.+)$")
MAX_LENGTH = 72
VAGUE = {"wip", "fix", "update", "changes", "stuff", "misc"}
def problems_in(subject: str) -> list[str]:
found = []
if subject.strip().lower() in VAGUE:
found.append("too vague for a reviewer")
if len(subject) > MAX_LENGTH:
found.append(f"longer than {MAX_LENGTH} characters ({len(subject)})")
match = SUBJECT.match(subject)
if match is None:
found.append("missing 'type: summary' prefix")
elif match["type"] not in ALLOWED_TYPES:
found.append(f"unknown type '{match['type']}'")
if subject.endswith("."):
found.append("ends with a period")
return found
def branch_subjects(base: str) -> list[str]:
result = subprocess.run(
["git", "log", "--reverse", "--format=%s", f"{base}..HEAD"],
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
raise SystemExit(f"git log failed: {result.stderr.strip()}")
return result.stdout.splitlines()
def main() -> int:
subjects = branch_subjects("main")
failures = 0
for subject in subjects:
problems = problems_in(subject)
print(("ok " if not problems else "FAIL") + " " + subject)
for problem in problems:
print(f" - {problem}")
failures += 1 if problems else 0
print(f"{failures} of {len(subjects)} commit messages need work")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())To test it, a branch was created off main with five commits — some with deliberately bad messages. (--allow-empty makes a commit without changing any file; that is enough for checking messages. In real work every commit carries a code change.)
git switch -c fix/uppercase-keywords
git commit --allow-empty -m "wip"
git commit --allow-empty -m "Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals"
git commit --allow-empty -m "fix(triage): lowercase text before matching."
git commit --allow-empty -m "feature: add tests"
git commit --allow-empty -m "test(triage): cover uppercase keywords"Then, from inside the repository:
uv run check_commits.pyFAIL wip
- too vague for a reviewer
- missing 'type: summary' prefix
FAIL Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
- longer than 72 characters (84)
- missing 'type: summary' prefix
FAIL fix(triage): lowercase text before matching.
- ends with a period
FAIL feature: add tests
- unknown type 'feature'
ok test(triage): cover uppercase keywords
4 of 5 commit messages need workThe program's exit code is 1, because at least one message failed. With everything fine it is 0.
The decisions in this script:
main..HEAD — this two-dot range means "commits in HEAD that are not in main". In other words, only this branch's own work. Checking the whole history would fail your PR over year-old commits written by other people. --reverse orders them oldest first, the order the work was done in.
--format=%s — %s means only the first line of the commit (the subject). No hash, date or author — so there is nothing to parse; every line is one message.
encoding="utf-8" — with text=True, Python uses the operating system's default encoding, which on Windows is often not UTF-8. If someone writes a commit message in Bengali or with an emoji, the output can break. Stating the encoding removes that uncertainty.
Its own message instead of check=True — when git fails (run outside a repository, or with no branch called main), the script shows git's own error message rather than a long Python traceback. Passing a string to SystemExit makes Python print it to stderr and stop with exit code 1.
The exit code — CI does not read output; it only looks at whether the program ended with 0. So main() returns a number, and sys.exit passes it on to the operating system.
When it breaks
Author identity unknown followed by fatal: unable to auto-detect email address Git does not know whose name to put on the commit. Run the two git config --global user.name ... and user.email ... commands from the start of the chapter once.
error: src refspec main does not match any There are no commits yet, so the main branch does not really exist — there is nothing to push. git add and git commit first, then push. The same message appears if the branch is actually called master; check which branch you are on with git branch.
fatal: The current branch feat/x has no upstream branch. A new branch is being pushed for the first time, and Git does not know which remote branch to send it to. The message gives the fix itself: git push --set-upstream origin feat/x (or git push -u origin feat/x for short). After that, plain git push.
! [rejected] main -> main (fetch first) Someone else pushed to the same branch since you last fetched. First integrate their work with git pull, then push. Do not "fix" it with git push --force — that wipes out your colleague's work.
warning: in the working copy of 'main.py', LF will be replaced by CRLF the next time Git touches it On Windows this is a warning, not an error. Windows and macOS/Linux end lines with different characters, and Git is telling you it will reconcile them. The commit went through fine.
.env was committed by mistake Run git rm --cached .env — it keeps the file on disk and only removes it from Git's tracking (the output is rm '.env') — then add .env to .gitignore and commit. But remember, the key is still in the earlier commit. If it has been pushed, there is no safe option other than revoking the key and creating a new one.
Step 4 of 6 — Predict
Check your understanding
Three messages are checked against the chapter's SUBJECT pattern. What prints?
import re
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(\([a-z0-9-]+\))?: (?P<summary>.+)$")
for subject in ["fix: handle empty input", "Fix: handle empty input", "fix:handle empty input"]:
print(SUBJECT.match(subject) is not None)- A`True`, `False`, `True`
- B`True`, `True`, `True`
- C`True`, `True`, `False`
- D`True`, `False`, `False`
A colleague "fixed" this function in check_commits.py. Now CI is green even on a branch with wip and feature: add tests commits, and the output is 0 of 0 commit messages need work. Where is the bug?
import subprocess
def branch_subjects(base: str) -> list[str]:
result = subprocess.run(
["git", "log", "--reverse", "--format=%s", f"HEAD..{base}"],
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
raise SystemExit(f"git log failed: {result.stderr.strip()}")
return result.stdout.splitlines()- AThe range is reversed: `HEAD..main` means "in `main` but not on this branch", so none of the branch's own commits come back; it should be `main..HEAD`
- B`--reverse` drops commits; removing it will show them
- CThe `returncode` check is discarding every commit; it needs `check=True`
- DWith `encoding="utf-8"`, `git` produces no output; it needs `text=True`
A PR description contains Closes #12. What does it do?
- AWhen the PR is merged into the default branch, the code-hosting service closes issue #12 by itself and keeps the two linked
- BIssue #12 is closed as soon as the PR is opened
- CNothing — it is just a note for the reviewer; no machine reads it
- DGit deletes commit number 12 from history
Play is part of Pro
+30 XP waiting
Play this chapter with Pro
Reading is free, and the whole chapter is open. The first 5 chapters of every free course are free to play. From chapter 6, answering the questions, earning XP and growing your streak is part of Pro.
Unlock Play with ProYour turn
Create a project on your own machine and run the whole cycle once:
- Create the project with
uv init my-triage --python 3.12, add.envto.gitignore, keep a.env.examplefile (containing justLLM_API_KEY=), confirm thatgit status --shortdoes not show.envor.venv, and make the first commit - Add a bare repository as the remote (
git init --bare ../my-triage-remote.git) and pushmain - Create a branch called
feat/priority-scoreand add this chapter'striage.pyandcheck_commits.pyin exactly three commits, each following the rules; push the branch - Write a
PR.md— What, Why, How I tested — and, if you have a code-hosting account, open a real PR there with this description
On the branch, uv run check_commits.py should end with exactly this line, preceded by three ok lines:
0 of 3 commit messages need workThen make a bad commit on purpose (git commit --allow-empty -m "wip"), run it again to see which two problems are caught, and fix it with git commit --amend -m "...". Or try this: replace main..HEAD with HEAD..main in the script, see what it prints, and work out why that is the most dangerous kind of mistake for a CI check.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Play with Pro