Version Control with Git
Keeping the history of your code with Git: commits, branches, merging, remotes and pull requests.
This page is an overview. The Git & CI/CD topic goes much deeper: how Git stores your work, merging and rebasing, undoing mistakes, collaborating on GitHub and automating with pipelines.
Why version control?
Without a system, people keep versions by copying files:
report-final.docx, report-final-2.docx,
report-final-really.docx. Nobody knows which copy is
current, what changed between them, or how to combine the edits of
two people.
Version control is the practice, and the software, of recording changes to a set of files over time, so that every earlier state can be retrieved and every change can be traced to its author and purpose. For programmers it provides:
- History — a complete log of every change, with the date, the author and a message explaining why.
- Undo — go back to any earlier version of a file, or of the whole project, after a mistake.
- Collaboration — several people can work on the same code at the same time and combine their changes safely.
-
Accountability — for every line you can ask who
last changed it and in which commit; Git's command for this is
called
git blame. The point is not to blame anyone, but to find the context of a change. - Experimentation — try an idea on a separate branch; if it fails, throw the branch away.
Centralized and distributed
In a centralized version control system, such as Subversion (SVN), the history lives on one server. You check out the current files, and every commit goes directly to that server — no network, no commits. In a distributed system, every developer has a full copy of the entire history on their own machine. You commit, browse history and create branches locally and offline, and exchange commits with other copies when you choose.
Git is distributed. Linus Torvalds wrote it in 2005 to manage the development of the Linux kernel, and it has since become the standard for almost all software development. Every copy being complete also helps in an emergency: if a server is lost, a developer's clone can often restore it — but it is no substitute for real backups.
Core concepts
Repository
A repository (or repo) is
the database in which a version control system stores a project's
complete history — all commits, branches and their metadata. In Git,
it is the hidden .git folder at the top of your
project. The rest of the project folder, the files you actually see
and edit, is the working tree.
The working tree, the staging area and the repository
Git does not save every change automatically. Between your files and the history sits a third area: the staging area (also called the index). It holds what will go into your next commit. This lets you choose: if you changed five files but only three belong to one logical change, you stage those three and commit them, and commit the other two separately.
Commits
A commit is a saved snapshot of the complete project at one moment, stored in the repository together with the author, a date, a message describing the change, and a reference to its parent — the commit that came before it. Following the parents from any commit leads all the way back to the first one, so the commits form the project's history.
Each commit is identified by a hash: a 40-character
hexadecimal fingerprint (SHA-1 by default) computed from its
content, metadata and parent. Change anything, and the hash changes.
You rarely type the whole hash; the first seven characters, such as
cd3a63e, are usually enough. Because each hash also
covers the parent, history cannot be altered silently: rewriting an
old commit changes the hash of every commit after it.
The basic workflow
Once per computer, tell Git who you are; this name and email address go into every commit you make:
git config --global user.name "Ada Lovelace"
git config --global user.email "ada@example.com"
Turn a folder into a repository with git init. Here, a
folder of recipes:
cd recipes
git init
Initialized empty Git repository in /home/ada/recipes/.git/
git status is the command you will type most. It tells
you which branch you are on and what state each changed file is in.
After creating a first file, pancakes.md:
git status
On branch main No commits yet Untracked files: (use "git add <file>..." to include in what will be committed) pancakes.md nothing added to commit but untracked files present (use "git add" to track)
Stage the file with git add, then record the commit
with git commit. The -m option gives the
message directly; without it, Git opens an editor for you to write
one.
git add pancakes.md
git commit -m "Add pancake recipe"
[main (root-commit) cd3a63e] Add pancake recipe 1 file changed, 4 insertions(+) create mode 100644 pancakes.md
After editing the file again, git diff shows the
changes that are not staged yet: removed lines start with
-, added lines with +.
git diff --staged shows what is staged, and
git diff HEAD everything since the last commit.
git diff
diff --git a/pancakes.md b/pancakes.md index 248d55d..61fb7e4 100644 --- a/pancakes.md +++ b/pancakes.md @@ -2,3 +2,4 @@ Pancakes - 200 g flour - 2 eggs +- 300 ml milk
Stage and commit that change too, then look at the history.
git log shows each commit with its full hash, author,
date and message; --oneline gives a compact summary,
newest first:
git add pancakes.md
git commit -m "Add milk to pancakes"
git log --oneline
[main 477d340] Add milk to pancakes 1 file changed, 1 insertion(+) 477d340 Add milk to pancakes cd3a63e Add pancake recipe
That is the everyday rhythm: edit, git status,
git add, git commit, repeat. Commit often,
in small steps that each do one thing.
Branches
A branch is an independent line of
development. In Git, a branch is nothing more than a movable, named
pointer to a commit. When you make a commit on a branch, the pointer
moves forward to the new commit. A special pointer,
HEAD, marks the branch you are on right now.
Because a branch is only a pointer, creating one is instant and costs nothing. The usual practice is to create a branch for every feature or fix, so the main branch always stays in a working state:
git switch -c add-syrup # create a new branch and switch to it
git switch main # switch back to an existing branch
git branch # list branches; * marks the current one
Older tutorials use git checkout -b add-syrup and
git checkout main; they do the same thing.
git switch was added in Git 2.23 because
checkout did too many different jobs.
Merging
A merge combines the changes of one branch into another. You switch to the branch that should receive the changes and name the branch to merge in. Git handles two situations differently.
Fast-forward merge
If the receiving branch has not changed since the other branch split off, there is nothing to combine: Git just moves the pointer forward to the newest commit. This is a fast-forward, and no new commit is created.
git switch main
git merge add-syrup
Updating 477d340..5502123 Fast-forward pancakes.md | 1 + 1 file changed, 1 insertion(+)
Merge commit
If both branches have new commits, as in the diagram above, Git performs a three-way merge: it compares both branch tips with their common ancestor (commit B in the diagram), combines the changes, and records the result as a merge commit — a commit with two parents. When the branches changed different files, or different parts of the same file, this happens automatically.
Merge conflicts
A merge conflict occurs when two branches changed the same part of the same file in different ways, so Git cannot decide automatically which version to keep. Git stops the merge and asks you to decide:
git merge metric
Auto-merging pancakes.md CONFLICT (content): Merge conflict in pancakes.md Automatic merge failed; fix conflicts and then commit the result.
In the file, Git has marked the conflicting region with
conflict markers. The part between
<<<<<<< and
======= is your current branch (HEAD); the
part between ======= and
>>>>>>> comes from the branch you
are merging:
Pancakes - 200 g flour <<<<<<< HEAD - 2 large eggs ======= - 3 eggs >>>>>>> metric - 300 ml milk - maple syrup, to serve
To resolve the conflict:
-
Edit the file so it contains what it should contain — one
version, the other, or a combination such as
- 3 large eggs— and delete all three marker lines. -
Mark the file as resolved with
git add pancakes.md. -
Finish the merge with
git commit. (Or, if you would rather start over, cancel it withgit merge --abort.)
Conflicts are a normal part of teamwork, not an error. They stay small and easy when branches live briefly and are merged often.
Remotes and collaboration
Remotes
A remote is another copy of the same repository, usually on a server, that your local repository knows by a short name and URL and exchanges commits with. Teams share work through a common remote, typically hosted on a platform such as GitHub, GitLab or Bitbucket. These hosts add a web interface, access control, issue tracking and code review on top of Git itself.
| Command | What it does |
|---|---|
git clone URL |
Copy a remote repository, with its complete history, into a
new local folder. The remote is registered under the name
origin.
|
git fetch |
Download new commits from the remote, without changing your own branches or files. You can inspect them first. |
git pull |
Fetch, then merge the remote branch into your current branch — bring your branch up to date. If both sides have new commits, Git 2.33 and later first ask how to combine them. |
git push |
Upload your new local commits to the remote branch. |
git remote -v |
List the remotes this repository knows. |
origin is just the conventional name for the remote you
cloned from; nothing about it is special. To publish an existing
local repository, create an empty repository on the host, add it as
a remote, and push. The -u option links your local
branch to the remote one, so later a plain git push or
git pull knows where to go:
git remote add origin git@github.com:ada/recipes.git
git push -u origin main
To github.com:ada/recipes.git * [new branch] main -> main branch 'main' set up to track 'origin/main'.
(Git also prints a few progress lines, which are left out here.)
When a colleague has pushed new commits, git pull
brings them in:
git pull
From github.com:ada/recipes 82d721f..6157f29 main -> origin/main Updating 82d721f..6157f29 Fast-forward pancakes.md | 1 + 1 file changed, 1 insertion(+)
If someone else pushed first, Git refuses your push until you have pulled and merged their commits. That protects their work from being overwritten.
That example was a fast-forward: only the remote had new commits.
When both sides have new commits, current Git (2.33 and later) stops
and asks how to combine them. Run
git pull --no-rebase to merge them, or
git pull --rebase to replay your commits on top of the
remote ones; or choose once with
git config --global pull.rebase false (merge).
Pull requests and code review
On a team, you usually do not push straight to
main. You push your feature branch and open a
pull request (GitLab calls it a
merge request): a request, made on the hosting platform, to
merge a branch into another, where the proposed changes can be
discussed, reviewed and tested before they are merged.
The platform shows the diff; teammates leave comments on specific
lines, ask questions and approve. Meanwhile, automated checks
(continuous integration) run the
test suite on the branch.
You answer by pushing more commits to the same branch, and the pull
request updates itself. When everything is approved and green,
someone clicks merge. Code review catches bugs, spreads knowledge
through the team and keeps the style consistent — which is why many
projects protect main so that changes can only arrive
through a reviewed pull request.
Good habits
.gitignore
Not everything in a project folder belongs in the repository. A file
named .gitignore at the top of the project lists
patterns of files that Git should ignore: they are not shown as
untracked and git add . skips them.
# Generated files __pycache__/ *.pyc build/ # Dependencies (reinstalled from a list) node_modules/ .venv/ # Secrets and local settings .env *.key # Editor and OS clutter .DS_Store .vscode/
Ignore what can be regenerated (compiled output, installed
dependencies), what is personal (editor settings), and above all
what is secret. Commit the .gitignore file itself, so
the whole team shares it.
.gitignore only affects files that Git is not tracking
yet. To stop tracking a file that is already committed, run
git rm --cached FILE (the file stays on your disk) and
commit. If that file held a secret, rotate the secret too: it stays
in the history.
Good commit messages
The history is only as useful as its messages. "fix", "changes" and "asdf" tell your future self nothing. The widely used convention:
- A short summary line, about 50 characters, in the imperative mood — as if completing "If applied, this commit will…": Add milk to pancakes, Fix crash on empty order list.
- If more is needed, a blank line, then a body that explains why the change was made. The diff already shows what changed.
- One logical change per commit. A commit that fixes a bug and renames twenty variables is hard to review and impossible to undo partially.
Undoing changes safely
Almost everything in Git can be undone, but the right command depends on where the change is:
| Situation | Command | Effect |
|---|---|---|
| Discard edits to a file you have not staged | git restore FILE |
The file returns to its staged version, or to its last committed state if nothing was staged. The edits are gone for good. |
| Unstage a file, keep the edits | git restore --staged FILE |
Moves the change out of the staging area only. |
| Undo a commit that is already shared | git revert COMMIT |
Makes a new commit that reverses the changes of the old one. History is kept. |
| Remove local commits that nobody else has | git reset COMMIT |
Moves the branch pointer back to an earlier commit. The later commits disappear from the branch. |
git revert 6157f29
[main 0f4b945] Revert "Add salt" 1 file changed, 1 deletion(-)
The key difference is between revert and
reset. Revert adds to history, so it is
safe, also on a branch other people have pulled (reverting a merge
commit needs -m 1 to say which parent to keep). Reset
rewrites history: if those commits were already pushed,
your colleagues' copies still contain them. Git then refuses your
push; forcing it replaces the remote history, and everyone who built
on the removed commits has to repair their copy. To force-push a
branch that only you work on, use
git push --force-with-lease: it refuses if the remote
has commits you have not fetched yet. By default
git reset keeps your changes as unstaged edits;
--soft keeps them staged; --hard throws
them away completely.