Git & GitHub#

This guide is a practical, end-to-end reference for using Git and GitHub in a research and machine-learning setting. It is written for researchers, students, and engineers who produce code, configurations, notebooks, and papers, and who need to collaborate without losing work, overwriting one another, or corrupting a shared history.

The emphasis throughout is on building an accurate mental model first and then layering commands on top of it. Most confusion with Git comes not from the commands but from not knowing what state the repository is in; once you have a clear mental model, the commands become much more intuitive and easier to recall.

Tip

You do not have to read this top to bottom. Many people already have a working knowledge of Git — use the section headers (and the “On this page” list below) to jump straight to the topics you want to level up on, and skip anything you already know.

Scope. This document covers version control with Git and collaboration on GitHub. It deliberately stops at the point where automation begins: automated testing and continuous integration and deployment (CI/CD with GitHub Actions) are substantial topics in their own right and are out of scope here.

Note

Hands-on practices are marked throughout with a Practice callout, and a dedicated Hands-On Exercises section at the end ties them together into short labs. You only need a terminal and a free GitHub account to follow along.

Why Version Control#

Research and machine-learning work is, at bottom, a sequence of experiments whose results must be trustworthy and reproducible. Without disciplined version control this breaks down quickly: code is emailed around, directories named final, final_v2, and final_REALLY_final accumulate, and six months later nobody can say which version of the code produced which figure in the paper.

Git is a distributed version-control system. It records snapshots of your project over time, lets many people work in parallel, and lets you move backward and forward through that history with confidence. Because it is distributed, every clone is a complete copy of the entire history — you can commit, branch, inspect, and diff completely offline.

GitHub is a hosting platform built on top of Git. It stores a shared copy of a repository in the cloud and adds the collaboration layer that Git itself lacks: pull requests, code review, issue tracking, access control, and automation.

For a research group specifically, version control delivers four things that are otherwise very hard to guarantee:

  • Provenance — every line of code is traceable to who wrote it, when, and why.

  • Reproducibility — any past result can be regenerated by checking out the exact commit (and, with discipline, the exact environment) that produced it.

  • Parallelism — several people can develop features and experiments simultaneously without stepping on each other.

  • Safety — committed and pushed work is extraordinarily hard to lose, which makes bold refactoring and experimentation low-risk.

Warning

That safety only applies to work you have pushed to a remote. A branch that lives only on your laptop is one hard-drive failure — or one bad rebase — away from being gone for good. If you have made substantial local changes, push them (even to a work-in-progress branch) so a copy exists on the server.

Important

Git and GitHub are not the same thing. Git is the tool that versions your files and runs entirely on your machine. GitHub is one of several websites (others include GitLab and Bitbucket) that host Git repositories and add collaboration features. Git is fully usable with no GitHub account at all.

Installation and First-Time Setup#

Install Git#

Use your distribution’s package manager:

sudo apt update && sudo apt install git    # Debian / Ubuntu
sudo dnf install git                        # Fedora / RHEL

Install via Homebrew (recommended) or the Xcode command-line tools:

brew install git
# or trigger Apple's installer:
xcode-select --install

Download and install Git for Windows, which bundles Git Bash — a terminal that provides the same commands shown throughout this guide.

Verify the installation:

git --version

Configure Your Identity#

Every commit is permanently stamped with an author name and email. Set these once so your contributions are attributed correctly, both locally and on GitHub.

git config --global user.name "Your Name"
git config --global user.email "you@example.com"

Important

Use the same email that is registered on your GitHub account. If the two differ, your commits will not be linked to your profile and will not appear in contribution graphs — a common and confusing source of “missing” work.

A few defaults are worth setting immediately; they prevent whole classes of beginner friction:

git config --global init.defaultBranch main   # name the first branch "main", not "master"
git config --global pull.ff only              # refuse surprise merge commits on pull
git config --global push.autoSetupRemote true # first push auto-creates the upstream branch
git config --global core.editor "nano"        # editor used for commit messages

Inspect your configuration at any time:

git config --list --show-origin   # every setting and which file it came from
git config user.email             # show a single value

Note

Configuration lives at three levels, each overriding the one above it: system (--system, the whole machine), global (--global, your user account), and local (--local, a single repository). Use a local override when one project needs a different identity — for example, a personal email on a work machine.

How Git Works (a Brief Look Inside)#

You do not need to know Git’s internals to use it, but a small, accurate mental model prevents the majority of confusion. This section is deliberately compact.

The Three Areas#

Every Git project has three places where a version of a file can live. Almost every Git command is, in effect, moving content between them:

Area

What it is

Working tree

The actual files on disk that you edit.

Staging area (the index)

A holding zone where you assemble exactly what your next commit will contain.

Repository (the .git directory)

The permanent, committed history of the project.

The everyday loop moves changes from left to right: you edit files in the working tree, stage the ones you want with git add, and commit the staged snapshot into the repository.

working tree  --( git add )-->  staging area  --( git commit )-->  repository
     ^                                                                  |
     +-----------------------( git restore / checkout )-----------------+

The staging area is the feature that distinguishes Git from simpler tools: it lets you craft a commit out of some of your changes while leaving the rest for a later, separate commit — the basis of clean, atomic history.

The Object Model, Commits, and Branches#

Under the hood, Git is a content-addressed store of four object types. You rarely touch them directly, but knowing they exist demystifies almost everything:

  • A blob stores the contents of a file; a tree stores a directory listing (names plus the blobs and sub-trees it contains); a commit points to one tree (a full snapshot of the project) plus metadata and its parent commit(s); a tag is a named pointer to a commit.

From this a few important properties follow:

  • A commit is a complete snapshot, not a diff. It records the whole project state, the author and date, a message, and a link to its parent. Chaining parents forms the history — more precisely a Directed Acyclic Graph (DAG) once branches and merges exist.

  • Every object is named by a SHA-1 hash of its content (e.g. a04dbce…). Because the hash covers the content and the parent, history is tamper-evident: change anything and every downstream hash changes. (This is also why rewriting history — see Merge vs. Rebase — is possible: it produces new commits with new hashes.)

  • A branch is nothing more than a lightweight, movable pointer to a commit. Creating one writes a single small file; this is why branching is cheap and encouraged. Committing advances the current branch pointer to the new commit.

  • HEAD is a pointer to where you are now — normally the branch you currently have checked out. A “detached HEAD” (discussed later) simply means HEAD points directly at a commit rather than at a branch.

Tip

Hold on to one idea above all: branching is cheap and local. A large share of Git’s power comes from creating short-lived branches freely and integrating them back.

Working with GitHub#

Everything so far has been entirely local. GitHub introduces a remote: a shared copy of the repository that you and your collaborators push to and pull from.

Authenticating: SSH vs. HTTPS#

GitHub no longer accepts an account password on the command line; you must use one of two mechanisms:

Method

How it works

Notes

SSH

Add your machine’s public SSH key to GitHub, then clone with git@github.com:… URLs.

Best for a machine you control; no prompts after setup. The same key concept used to access the clusters.

HTTPS + token

Clone with https:// URLs and authenticate with a Personal Access Token (PAT) in place of a password.

Useful where SSH is blocked by a firewall. Store the token with a credential helper to avoid re-entering it.

To set up an SSH key (the same procedure used for cluster access):

ssh-keygen -t ed25519 -C "you@example.com"   # generate a key pair
cat ~/.ssh/id_ed25519.pub                     # copy this PUBLIC key into GitHub → Settings → SSH keys
ssh -T git@github.com                         # test the connection

Important

Only ever upload your public key (the .pub file). Your private key must never be shared, copied to a shared machine, or committed to a repository.

Remotes: Connecting Local to GitHub#

A remote is a named URL pointing at a hosted repository. By convention the primary remote is called origin.

git remote -v                                       # list configured remotes
git remote add origin git@github.com:you/repo.git   # link a local repo to GitHub
git push -u origin main                             # first push; -u sets the upstream

Once the upstream is set, plain git push and git pull operate on the correct branch automatically.

Push, Fetch, and Pull#

Command

What it does

git push

Upload your local commits to the remote.

git fetch

Download remote changes without touching your working files — for inspection first.

git pull

git fetch plus integrate the changes into your current branch, in one step.

Tip

When collaborating, prefer git fetch followed by a review of git log origin/main before integrating. A reflexive git pull can trigger a surprise merge or conflict at an inconvenient moment.

Visualising Branch History#

Reading the commit graph is a skill, and good visual tools make it effortless:

  • The GitHub network graph. Every repository has an interactive branch/commit graph at …/network — for example, the public github.com/Amii-Engineering/amii-tools/network. It is the quickest way to see how branches diverged and merged.

  • The GitHub Desktop app and the repository’s “Insights → Network” view show the same history graphically.

  • In your editor (VS Code, Cursor, …), the GitLens extension is invaluable: it draws the branch graph, and — most useful day to day — when you hover over any line of code it shows the last commit that changed it, with a click-through link to view that commit on GitHub. It surfaces a great deal of “who changed this and why” without leaving the file.

Practice — publish a repository

  1. Create a new, empty repository on GitHub (no README).

  2. In a local project, run git remote add origin <url> and git push -u origin main.

  3. Edit a file through GitHub’s web UI and commit it there, then run git pull locally and watch the change arrive. Finally, open the repo’s /network graph to see it.

The Solo Workflow#

This is the core loop you will use every day, long before any collaboration enters the picture.

Starting a Repository#

You either create a new repository or copy an existing one:

# Option A: start a brand-new repository in the current folder
git init

# Option B: copy an existing repository (SSH URL preferred — see "Working with GitHub")
git clone git@github.com:Amii-Engineering/amii-docs.git

Checking Status and Staging#

git status is the single most useful command in Git: it reports what has changed, what is staged, and which branch you are on. Run it liberally — before and after every other command — until the repository’s state is always clear to you.

git status                  # what changed? what is staged? which branch?
git add experiment.py       # stage one specific file
git add -u                  # stage changes to tracked files only (no new files)
git add -A                  # stage everything: modifications, new files, and deletions
git restore --staged file   # unstage (keep the edits, just remove from the next commit)

Tip

Use git add -p to stage changes hunk by hunk. This is invaluable for splitting a messy working tree into several small, coherent commits instead of one sprawling one.

Committing#

A commit records the staged snapshot together with a message.

git commit -m "Add learning-rate sweep to training script"

# stage all tracked, modified files AND commit in one step:
git commit -am "Fix off-by-one error in data loader"

Note

The commit summary is where you — and every future developer, including yourself — come to understand why a change was made. A conventional style keeps that history readable: write the summary in the imperative mood (“Add”, “Fix”, “Refactor”), keep it short, and add a body when the reasoning is not obvious. Many teams adopt Conventional Commits (feat:, fix:, docs: …); see Commit Message Conventions.

Inspecting History and Differences#

git log --oneline --graph --decorate --all   # compact, visual history of all branches
git show <commit>                            # exactly what one commit changed
git diff                                     # unstaged changes (working tree vs index)
git diff --staged                            # staged changes (index vs last commit)
git blame <file>                             # who last changed each line, and in which commit

Ignoring Files with .gitignore#

Not everything belongs in version control. A .gitignore file lists patterns that Git should never track. Getting this right from the first commit is critical for research repositories, which otherwise fill up with large data files, model checkpoints, and — most dangerously — secrets.

# Python
__pycache__/
*.pyc
.venv/

# Data & model artifacts (keep these out of Git — see "Large Files and Datasets")
data/
*.csv
*.ckpt
*.pt

# Secrets & environment
.env
*.key

# Editor / OS noise
.vscode/
.DS_Store

Important

.gitignore only affects files Git is not already tracking. If a file was already committed, adding it to .gitignore does nothing until you stop tracking it with git rm --cached <file> (this keeps the file on disk but removes it from the index).

Practice — your first repository

  1. Create a folder, run git init inside it, and add a README.md.

  2. Stage and commit it, then run git log --oneline to see your commit.

  3. Create a .env file, add .env to .gitignore, and confirm with git status that Git now ignores it.

Branching and Merging#

Branches let you develop a feature, experiment, or fix in isolation without disturbing a known-good branch. They are the foundation of both safe solo experimentation and team collaboration.

Creating and Switching Branches#

git branch                      # list local branches (current one marked with *)
git switch -c feature/new-loss  # create AND switch to a new branch
git switch main                 # switch back to main
git branch -d feature/new-loss  # delete a branch once it is merged

Note

git switch (change branches) and git restore (discard file changes) are the modern, purpose-built commands. The older git checkout still does both jobs — git checkout -b feature/new-loss is equivalent to git switch -c feature/new-loss — and you will see it throughout older tutorials and answers.

Tip

Adopt a branch naming convention and use it consistently, e.g. feature/…, fix/…, experiment/…, docs/…. Prefixes make the branch list self-documenting and easy to filter.

Merging#

When the work on one branch is ready, you integrate it into another with git merge. You switch to the branch that should receive the work, then merge the other branch into it. For example, to fold a small fix branch into a larger feature branch:

git switch feature/binary-classification   # the branch that will receive the work
git merge fix/new-loss                      # merge the fix branch into it

There are two possible outcomes:

  • Fast-forward: if the receiving branch has not moved since you branched, Git simply advances its pointer to your commits. No new commit is created, and the fact that a separate feature branch ever existed disappears from the history.

  • Merge commit: if both branches have new commits, Git combines them and records a new merge commit with two parents, preserving the fact that the work happened in parallel.

git merge --no-ff fix/new-loss

The --no-ff flag forces Git to always create a merge commit, even when a fast-forward would be possible. This deliberately preserves the historical existence of the feature branch and groups its commits together under one merge — which makes the DAG far easier to read and a feature far easier to revert as a unit. Many teams standardise on --no-ff merges into shared branches for exactly this reason (see the git-flow model).

Note

Branches can branch off branches. Nothing forces every branch to start from main or staging. It is common to branch a small fix/… off a larger feature/…, merge it back in as above, and let several such sub-branches accumulate on the feature until it is a coherent unit of work. That whole unit is then rolled up into a release and pull-requested into a shared branch (staging/main) in one go.

git merge vs. a Pull Request — when to use which

A git merge on your own machine and a Pull Request on GitHub both integrate one branch into another, but they serve different purposes:

  • A local merge is the right tool for integrating your own branches together — folding a fix into your feature, or keeping a private branch current. It is immediate and leaves no record on GitHub.

  • A Pull Request is the right tool for landing work in a shared branch (main, staging, a QA/production environment). It exists to get the change reviewed before it lands, and it leaves a durable, searchable record on GitHub: every closed PR is a self-contained “here is a chunk of work that shipped, and why.” Reviewing the list of merged PRs is far easier than archaeology through the raw commit graph hunting for where individual branches were merged in.

A good habit — even when you work alone — is to always land work on main/staging through a PR rather than a direct local merge. It keeps the same flow familiar, and it gives you the same reviewable history you would want on a team. (In practice most teams also enable branch protection on these branches, which requires a PR anyway.)

Merge vs. Rebase#

Both integrate one branch’s work into another, but they produce very different histories. This distinction is one of the biggest “level-ups” in day-to-day Git, so it is worth studying the pictures below.

Approach

What it does

When to use

Merge

Joins histories with a merge commit, preserving the true, branching shape of events.

Integrating a finished branch into a shared branch; anything already public.

Rebase

Replays your commits on top of another branch, producing a clean, linear history.

Updating and tidying your own feature branch before you open or update a PR.

The problem rebase solves. Imagine your feature branch falls behind staging while you work. If you keep merging staging into your feature to stay current, the history fills with back-and-forth merge commits:

*   Merge staging into feature (again)
|\
| * work on staging
* | your commit
|/
*   Merge staging into feature
|\
| * work on staging
* | your commit
|/
*   branch point

When this branch is finally merged back, the DAG is a tangle of “in and out” merges that is hard to follow. Rebasing instead lifts your commits and replays them on top of the latest staging:

git fetch origin
git rebase -i origin/staging   # replay MY commits on top of the latest staging
Before rebase (feature was branched off E):

      A---B---C  feature
     /
D---E---F---G  staging

After rebase (A B C are replayed on top of G, the tip of staging):

              A'--B'--C'  feature
             /
D---E---F---G  staging

Now your work sits in a single straight line on top of staging. When the PR is merged, the DAG shows one clean branch-off and one clean merge-back instead of a jumble — the history becomes genuinely easy to read. (The interactive -i flag additionally lets you reword, squash, or reorder your own commits along the way, e.g. collapsing five “work in progress” commits into one.)

Warning

Rebasing rewrites history. Because A', B', C' are new commits with new hashes, they are no longer the commits anyone else may have based work on. If a teammate has already checked out your branch, pulled A B C, and added their own commit, then after you rebase and force-push they will end up with both the old and the new copies of your commits, plus conflicts, when they next pull:

Teammate's local branch after pulling your rebased branch:

...--A---B---C---(their work)      <- the original commits, still here
      \
       A'--B'--C'                  <- your rebased copies, now also here

This is confusing and painful to untangle. Only rebase commits that are yours and unshared. If in doubt, don’t.

Pushing after a rebase. Because you have rewritten the branch, a normal git push is rejected — your local history no longer matches the remote. You must force the push:

git push --force-with-lease    # preferred: refuses if someone else updated the branch
git push -f                    # blunt force: overwrites the remote unconditionally

Prefer --force-with-lease: it still overwrites your feature branch, but aborts if the remote has commits you have not seen (a sign someone else is working on it).

Important

Rebasing is powerful but unforgiving, so build muscle memory on a throwaway branch first, then use it for real. And remember the escape hatch: at any point during a rebase you can run git rebase --abort to return to exactly where you started. If you get lost or the state stops making sense, abort — nothing is lost.

A Branching Model for Team & Production Work#

The commands above are neutral about how a team organises its branches. Adopting a shared model turns Git from a personal tool into a release process — and it is what separates hobby repositories from ones that ship reliably to clients. The model below is a lightly simplified git-flow and is a good default for product and client work.

Branch

Role

main (or master)

Production. This is what is served to customers — the clients your work ships to. It must always be the latest stable release, with forward-facing documentation. Nothing lands here until it is ready to go live.

staging

The next release. An integration branch that accumulates finished features and is stabilised until it is ready, at which point it is merged into main.

feature/…

Work in progress. Each feature or fix branches off staging, and is merged back into staging (via a pull request), never directly into main.

The everyday flow is therefore:

  1. Branch feature/x off staging.

  2. Do the work; keep the branch current by rebasing onto origin/staging (see above).

  3. Open a PR from feature/x into staging. In practice the merge itself happens on GitHub: once the review comments are addressed and checks pass, you click the green Merge button (the merge-commit option corresponds to --no-ff) and you are done.

  4. When staging is ready to release, merge staging into main and tag the release.

Tip

Treating main as sacred, always-deployable production is the single habit that most improves stability for the client. It also prepares staff for how the wider software industry works. Read the original “A successful Git branching model” for the full picture, including hotfix and release branches.

Undoing Things#

Almost nothing that has been committed is truly lost, but the correct command depends entirely on the situation. Choosing the wrong one — especially reset --hard — is the most common way beginners lose work, so it is worth internalising this table.

You want to…

Command

Discard unstaged changes to a file

git restore <file>

Unstage a file (but keep the edits)

git restore --staged <file>

Amend the last commit’s message or contents

git commit --amend

Undo the last commit but keep the changes staged

git reset --soft HEAD~1

Undo the last commit and unstage the changes (keep the files)

git reset HEAD~1 (mixed — the default)

Undo the last commit and discard the changes entirely

git reset --hard HEAD~1 ⚠️

Undo a commit that is already pushed / shared

git revert <commit>

Note

HEAD~1 means “one commit before HEAD”; HEAD~2 is two before, and so on. So git reset HEAD~3 rewinds the branch pointer back by three commits. You can also name a commit directly by its hash.

Important

git reset moves your branch pointer and rewrites history — it is only safe on commits you have not shared. git revert instead creates a new commit that cancels out an old one, leaving history intact; this is the correct choice for anything already pushed.

Note

In day-to-day team work, much of this “undo a shared change” happens through the GitHub web UI rather than the command line. Once work is merged you are usually in review mode rather than code-writing mode, and a merged PR has a one-click Revert button that opens a ready-made PR backing the change out — the frontend equivalent of git revert. See Working in a Team.

Warning

git reset --hard permanently discards uncommitted changes in your working tree. There is no undo for work that was never committed. When in doubt, git stash first (below) to set changes aside safely.

Stashing#

git stash parks your uncommitted changes so you can switch context (for example, to a quick fix on another branch) with a clean working tree, then restores them afterward.

git stash            # set aside all uncommitted (tracked) changes
git stash -u         # include untracked files as well
git stash list       # see stashed entries
git stash pop        # reapply the most recent stash and drop it from the list

Recovering “Lost” Commits with the Reflog#

Git records every movement of HEAD in the reflog. Even after a mistaken reset or a deleted branch, the commit almost always still exists and can be recovered.

git reflog                       # list recent HEAD positions with their hashes
git switch -c recovered <hash>   # bring a "lost" commit back on a new branch

Tip

The reflog is your safety net. Before assuming work is gone, run git reflog — the commit is typically still reachable for at least 30 days.

Collaborating in a Team#

This is where Git delivers the most value — and where most problems occur. The workflow below, the feature-branch + pull-request model, is what this documentation repository itself uses, and it scales from two people to hundreds.

The Pull-Request Workflow#

  1. Sync your local base branch (staging in the model above, or main in simpler repos) with the remote:

    git switch staging
    git pull
    
  2. Branch for your task, using a descriptive, prefixed name:

    git switch -c feature/add-fsdp-example
    
  3. Work, making focused commits as you go.

  4. Push the branch to GitHub:

    git push -u origin feature/add-fsdp-example
    
  5. Open a Pull Request (PR) from your branch into the base branch. Describe what changed and why, and link any related issue. Open it as a draft if it is not yet ready for review.

  6. Review: teammates comment, request changes, or approve. Push additional commits to the same branch to address feedback — the PR updates automatically.

  7. Merge the PR once it is approved and any required checks pass.

  8. Clean up: delete the merged branch and update your local base branch:

    git switch staging
    git pull
    git branch -d feature/add-fsdp-example
    

Fork vs. Branch#

There are two collaboration models; choose based on whether you have write access to the repository:

Model

When to use

Shared repository (branches)

You are a member of the team or organisation with write access. Everyone pushes branches to the same repository and opens PRs internally. This is the typical internal-team setup.

Fork & pull

You are an outside contributor without write access. You fork (create your own server-side copy), push branches to your fork, and open a PR from your fork back to the original (“upstream”) repository. This is the standard way to contribute to open-source projects.

When you fork, keep your copy current by adding an upstream remote:

git remote add upstream git@github.com:Amii-Engineering/amii-docs.git
git fetch upstream
git switch main
git merge upstream/main      # bring your fork's main up to date

Keeping a Branch Up to Date#

Long-lived branches drift from their base. Integrate regularly to keep each reconciliation small. For a personal feature branch, rebasing onto the latest base keeps history linear (see Merge vs. Rebase):

git switch feature/my-work
git fetch origin
git rebase origin/staging    # linear history; or `git merge origin/staging` to preserve merges

Tip

Integrate from the base branch frequently and in small steps. Ten small conflicts spread across a week are far easier to handle than one enormous conflict at merge time.

Reviewing Code Well#

Code review is where a team’s quality and shared understanding are built. A few habits make it effective:

  • Keep PRs small and focused. A reviewer can reason carefully about 200 lines; a 1,000-line PR gets a rubber-stamp. Small PRs merge faster and hide fewer bugs.

  • Review the *why*, not just the *what*. The PR description should let a reviewer judge whether the approach is sound, not only whether the code compiles.

  • Be specific and kind. Comment on the code, not the author; prefer questions and suggestions over demands.

Reviewing in the age of generative coding tools

A growing share of code now originates from AI assistants and agents (Claude, Copilot, Gemini, Codex, …), and some teams even have agents perform a first pass of PR review. The industry is still working out the norms here, but the centre of gravity of review has shifted:

  1. Did it build the right thing? Modern agents are very good at producing code that runs and is locally correct, so review is less about hunting for subtle syntax bugs and more about verifying that what was built actually matches what was desired — the right feature, the right output, the intended behaviour and edge cases.

  2. Does the change fit the whole? Earlier agents lacked a picture of the broader codebase and would duplicate logic or bolt on approaches that did not fit; that gap has largely closed, but it is still worth confirming the change belongs where it landed and is consistent with the surrounding architecture.

Commit Message Conventions#

A consistent commit-message format makes history readable and machine-parseable. A widely used standard is Conventional Commits, in which each message begins with a type:

<type>: <short summary in the imperative mood>

[optional body explaining the what and why]

Common types:

Type

Meaning

feat

A new feature or section.

fix

A bug fix.

docs, refactor, style, test, chore, ci

Documentation, restructuring, formatting, tests, and other supporting changes.

Note

The convention can be enforced automatically so it does not rely on everyone remembering it. A commitlint hook (wired in through pre-commit or Husky, and run in CI) checks each commit message as it is created and rejects anything that does not match the <type>: <summary> shape.

Practice — a realistic pull request

  1. Fork or clone a sandbox repository and create feature/add-greeting.

  2. Add a line to a file, commit it as feat: add greeting to readme, and push.

  3. Open a PR on GitHub, request a review (from yourself or a teammate), then merge it.

  4. Delete the branch and run git pull on the base branch to retrieve the merge.

Resolving Merge Conflicts#

A conflict arises when two branches change the same lines of a file in different ways. Git cannot know which version is correct, so it pauses and asks you to decide. Conflicts are a normal part of collaboration, not an error, and resolving them calmly is an essential skill.

When a merge or pull stops with a conflict, Git marks the affected region inside the file:

<<<<<<< HEAD
learning_rate = 0.001          # your version (current branch)
=======
learning_rate = 0.0005         # their version (incoming branch)
>>>>>>> feature/tune-lr

To resolve it:

  1. List the conflicted files with git status and open each one.

  2. Edit the region into the desired final result, deleting the <<<<<<<, =======, and >>>>>>> marker lines entirely.

  3. Stage the resolved file and complete the merge:

    git add config.py
    git commit              # completes the merge (the message is pre-filled)
    

To abandon the merge and return to the pre-merge state:

git merge --abort

Important

Editor buttons like VS Code’s “Accept Current / Incoming / Both” are convenient for trivial conflicts, but for complex projects prefer resolving conflicts by hand. Real conflicts are often too nuanced to settle by picking one side wholesale — the correct result is frequently a careful blend of both changes, and only reading the code closely will get it right.

Common Problems and How to Fix Them#

This section collects the situations researchers and collaborators hit most often, working both alone and in a team.

Working Alone#

“Detached HEAD” state.

You ran git checkout <commit-hash> and Git warns about a detached HEAD. You are viewing an old commit rather than a branch, and new commits made here can be lost. To keep any work, create a branch: git switch -c my-branch. To simply leave, git switch main.

Committed to the wrong branch.

Which fix you need depends on the situation:

If you intended to start a new feature branch anyway — you simply committed on main before creating it — branch off, then rewind main:

git switch -c feature/intended      # the new branch includes your commit
git switch main
git reset --hard origin/main        # rewind main (only if main is not yet pushed!)

If the feature branch already exists and you committed to main by accident, copy the commit over with cherry-pick and then roll main back:

git switch feature/existing
git cherry-pick <the-stray-commit>   # copy it onto the right branch
git switch main
git reset --hard origin/main         # remove it from main (only if main is not yet pushed!)
Accidentally committed a large data file or a secret.

Removing it in a new commit is not enough — it remains in history. For the most recent commit, git rm --cached bigfile && git commit --amend. For deeper history, use git filter-repo or the BFG tool. If a secret was committed, rotate it immediately — assume it is compromised the moment it was pushed.

Your branch and origin/main have diverged.

Both you and the remote have new commits. Integrate them with git pull (which creates a merge) or git pull --rebase (which replays your commits on top for a linear history), then push.

Orphan / disconnected branches.

Occasionally someone starts a “clean slate” — often to try a radically different approach — with git checkout --orphan or by importing an unrelated history. This creates a second, disconnected DAG inside the same repository: a branch that shares no common ancestor with main and can never be cleanly merged back. Avoid this. It makes the project’s history impossible to follow, breaks CI/CD assumptions, and leaves it unclear what is actually delivered to the customer. To explore a very different approach, branch off the existing history like anything else (or use a separate repository) — an orphan branch is almost never what you want.

Working in a Team#

Push rejected (``! [rejected] … fetch first``).

Someone pushed before you, so your local branch is behind. Run git pull (resolving any conflicts), then git push again. Do not force-push a shared branch to sidestep this.

The temptation to force-push.

git push --force overwrites the remote branch and can erase teammates’ commits. If you genuinely must (for example, after rebasing your own feature branch), use the safer git push --force-with-lease, which refuses to push if someone else has updated the branch since your last fetch. Never force-push shared branches such as main or staging — and in practice most repositories enable branch protection, which explicitly forbids force-pushing (and often direct pushing) to them at all.

A merged PR introduced a bug.

Use git revert to add a new commit that undoes the change — safe for shared history — rather than rewriting the branch. Many people find this easiest from the GitHub web UI: open the merged PR and click the “Revert” button, which opens a ready-made PR that backs the change out.

Line-ending noise (Windows vs. Linux/macOS).

Whole files appear “changed” with no real edits. Normalise line endings with a .gitattributes file containing * text=auto, and set git config --global core.autocrlf input (Linux/macOS) or true (Windows).

Large Files and Datasets#

For AI/ML work this deserves special attention: datasets and model checkpoints are large, binary, and constantly changing — exactly what plain Git handles worst.

Why not just commit them? Git stores the full history of every file forever. Commit a 2 GB checkpoint, change it ten times, and every single clone now carries ~20 GB that can never be removed without rewriting history. Binaries also cannot be diffed or merged, so they bloat the repository while providing none of Git’s benefits. Keep data and checkpoints out of Git (via Ignoring Files with .gitignore) and store the payload on appropriate storage — an object store, a shared filesystem, or the cluster’s project space.

When large files genuinely must be versioned, use Git LFS (Large File Storage). LFS keeps a tiny text pointer in the Git history while the real bytes live on a separate LFS server, so clones stay small and history stays clean:

git lfs install                    # one-time setup per machine
git lfs track "*.pt" "*.ckpt"      # choose which patterns LFS should handle
git add .gitattributes             # LFS records the tracked patterns here — commit it
git add model.pt
git commit -m "Add trained checkpoint via LFS"
git push                           # the pointer goes to Git; the bytes go to LFS storage

Cloning and pulling then work as usual — git clone / git pull fetch the pointers and transparently download the real files from LFS. For heavier, pipeline-style data versioning (datasets tied to experiments), consider a dedicated tool such as DVC.

Using Git with Generative Coding Tools#

AI coding assistants and agents (Claude Code, the Gemini CLI, Codex, and others) now write, edit, and even commit code directly. Git is what keeps that productive rather than dangerous — it is the safety layer that lets you accept the good and discard the rest.

  • Keep agent work on a branch. Point assistants at a feature/… branch, never straight at main or staging. A branch is a sandbox you can throw away.

  • Commit in small, reviewable steps. Frequent commits let you inspect each change with git diff and cleanly git revert or git reset anything the agent got wrong, instead of untangling one giant change.

  • Always review the diff before you push. Treat generated code exactly like a teammate’s PR: the code is usually locally correct, so focus on whether it built the right thing and fits the surrounding codebase (see Reviewing Code Well).

  • Guard secrets and large files. Agents can accidentally stage a .env file or a checkpoint; a solid .gitignore and a quick git status before committing are your guardrails.

  • Let them help with Git itself. These tools are genuinely useful for drafting commit messages, explaining a confusing diff, or talking you through a merge conflict — but you remain responsible for what actually gets committed.

Best Practices for Researchers#

  • Commit early, commit often, in logical units. A commit should capture one coherent change, not a day’s worth of unrelated edits. Atomic commits are easier to review, revert, and understand later.

  • Write meaningful messages. “Fix bug” is useless in six months; state what changed and why. Follow Commit Message Conventions.

  • Never commit secrets or data. API keys, credentials, and datasets do not belong in Git. Use .gitignore and environment variables, and if a secret leaks, rotate it.

  • Branch per task. Keep production branches always working; do experiments and features on branches.

  • Integrate frequently. Pull before you push, and rebase/merge from the base branch often to keep conflicts small.

  • Push work you care about. A local-only branch is not backed up — see the warning under Why Version Control.

  • Version the environment, not just the code. Commit your pyproject.toml / lockfile / requirements.txt alongside the code so the exact dependencies behind a result can be reproduced.

  • Keep notebooks reviewable. Notebook outputs and execution counts create large, noisy diffs and frequent conflicts. Strip outputs before committing (e.g. with nbstripout) or pair each notebook with a plain-text script (e.g. with jupytext).

Tag Releases and Paper Submissions#

A tag marks a permanent, named point in history — ideal for “the exact code behind this result.” Annotated tags are best because they carry a message and author:

git tag -a v1.0 -m "NeurIPS 2026 submission"
git push origin v1.0        # tags are not pushed by default; push them explicitly

Once pushed, the tag appears on the repository’s main page under Releases/Tags. From there — or by turning the tag into a GitHub Release — anyone can view that snapshot and download a .zip or .tar.gz of the repository exactly as it was at that commit. This is a wonderful habit for reproducibility: link the release (or its commit hash) in your paper, and reviewers or future readers can retrieve precisely the code that produced your numbers.

Tips & Tricks#

A grab-bag of high-leverage habits, especially useful once the fundamentals are second nature:

  • Make an alias for the graph. You will use it constantly. A richly formatted version colours the hash, relative date, subject, and author, which makes the history far easier to scan:

    git config --global alias.graph "log --graph --oneline --decorate --all --format=format:'%C(bold blue)%h%C(reset) - %C(bold green)(%ar)%C(reset) %C(white)%s%C(reset) %C(dim white)- %an%C(reset)%C(auto)%d%C(reset)'"
    # then just:  git graph
    
  • Stage interactively with git add -p to build small, coherent commits.

  • Fix an earlier commit cleanly with git commit --fixup <sha>. This makes a normal commit tagged to say “this belongs to <sha>”. Later, running git rebase -i --autosquash <sha>~1 reorders the fixup commit next to its target and marks it to be squashed in, so the correction folds silently into the original commit with no new entry in the history. It is the clean way to amend a commit that is not the most recent one (where a plain git commit --amend would do). Set git config --global rebase.autosquash true to make --autosquash the default.

  • Grab a single commit from another branch with git cherry-pick <sha>.

  • Find the commit that introduced a bug with git bisect — a guided binary search across history that pinpoints the culprit in a handful of steps. For a quick hunt, many people instead reach for blame on the GitHub frontend: open the file, click Blame, and step back through the revisions of a line to find when a change was introduced. git bisect shines when the bug is not tied to one obvious line and you need to binary-search behaviour across the history instead.

  • Work on two branches at once with git worktree add ../other-branch other-branch. A worktree checks a second branch out into its own folder that shares the same repository, so you can have two branches “live” on disk at the same time — no stashing or committing half-done work to switch. It is handy when, say, a build or long training run is going on one branch and you want to start a fix on another without disturbing it, or to compare two branches side by side in two editor windows.

  • Always prefer git push --force-with-lease over git push --force.

  • Let Git remember conflict resolutions with git config --global rerere.enabled true so you do not re-solve the same conflict twice during a long rebase.

  • Use GitLens (or your editor’s Git integration) to see per-line commit history on hover — see Visualising Branch History.

  • Do not commit large files — use Git LFS instead (see Large Files and Datasets).

Hands-On Exercises#

Work through these in order; together they exercise the full workflow.

Lab 1 — Local fundamentals

Initialise a repository, make three commits to a README.md, then use git log --oneline, git diff, and git show to inspect them. Finally, use git restore to discard an uncommitted edit.

Lab 2 — Branching, merging, and rebasing

From main, create feature/x and feature/y. Add a different line to the same file on each branch. Merge feature/x, then merge feature/y and resolve the resulting conflict by hand. Bonus: on a fresh branch, add two commits, then git rebase -i onto main and squash them into one — compare the graph before and after with git graph.

Lab 3 — Undo and recover

Make a commit, then git reset --hard HEAD~1 to “lose” it. Recover it using git reflog and git switch -c recovered <hash>. Separately, practise git revert on a pushed commit.

Lab 4 — End-to-end collaboration

Push a repository to GitHub, open a pull request from a feature branch, request a review, merge it, delete the branch, and sync your local base branch. Then open the repo’s /network graph and find your merge. Bonus: fork a public repository, add an upstream remote, and keep your fork up to date.

Quick Reference#

Command

Purpose

git status

Show changed and staged files, and the current branch.

git add <file> / git add -p

Stage a file / stage changes interactively, hunk by hunk.

git add -u / git add -A

Stage tracked changes only / stage everything including new files.

git commit -m "msg"

Record the staged snapshot.

git log --oneline --graph

View compact, visual history.

git switch -c <branch>

Create and switch to a new branch.

git merge --no-ff <branch>

Merge a branch, always creating a merge commit.

git rebase -i origin/staging

Replay your commits on top of the base branch for a linear history.

git fetch / git pull / git push

Download / download+integrate / upload commits.

git push --force-with-lease

Safely push a rebased branch (aborts if others pushed).

git restore <file>

Discard unstaged changes to a file.

git reset --soft HEAD~1

Undo the last commit, keeping the changes staged.

git revert <commit>

Safely undo a pushed commit with a new commit.

git cherry-pick <commit>

Copy a single commit onto the current branch.

git stash / git stash pop

Park / restore uncommitted changes.

git reflog

Recover “lost” commits and branch tips.

git tag -a <name> -m "…"

Mark a reproducible point in history.

Further Resources#