Working tree, staging area and commits
Git tracks your project in three places. The working tree is the files you edit, and the staging area, also called the index, holds the changes you have picked for the next commit. A commit saves that staged snapshot permanently in the repository's history.
You move changes forward with two commands. The git add command copies a change into the staging area, and git commit records everything staged. With staging, you can commit part of your work, such as one fix out of several edited files, and git add -p stages individual chunks of a file.
Each commit stores a snapshot of the tracked files, the author, a message and a pointer to its parent commit. Git identifies it by a hash of that content (SHA-1 by default), so changing anything in a commit or its history produces a different ID.
Every clone holds the full history, so most commands work offline. Sharing happens only when you push to or pull from a remote, such as a repository on GitHub or GitLab.
Branches are pointers to commits
A branch in Git is a movable label that points to one commit. When you commit on a branch, Git creates the new commit with the current one as its parent and moves the label forward. HEAD is the pointer to the branch you have checked out.
That makes branches cheap. Creating one writes a small reference and copies no files, so a branch per feature or bug fix costs almost nothing. Deleting a merged branch removes only the label, and its commits stay in the history of the branch they were merged into.
If you check out a commit directly instead of a branch, you are in detached HEAD state. Commits made there belong to no branch, so create one with git switch -c before switching away.
Merge vs rebase
Both commands bring one branch's work into another, and they differ in the history they leave. A merge joins the two lines of history with a merge commit that has two parents. If the target branch hasn't moved, Git fast-forwards the label instead and creates no merge commit.
A rebase replays your commits one by one on top of another branch, producing new commits with new hashes. The result reads as a straight line, as if you had started your work from the latest main.
Merging never rewrites commits, so it is always safe on shared branches. Rebasing gives a cleaner history but rewrites it, so only rebase commits nobody else has built on, such as your own feature branch. Push the result with git push --force-with-lease, which refuses to overwrite remote commits you haven't fetched.
Hosting platforms such as GitHub and GitLab also offer squash merging, which combines a pull request's commits into a single commit on main.
A simple feature branch and pull request workflow
Most teams make each change on a short-lived branch and merge it through a pull request (PR), where automated checks run and a teammate reviews the diff. Small, short-lived branches make reviews faster and merge conflicts rarer.
Write commit messages that say why the change was made, since the diff already shows what changed. After review and passing checks, merge the PR and delete the branch. A typical sequence:
# Start from an up-to-date main
git switch main
git pull
# Create a branch for the change
git switch -c fix/invoice-rounding
# Stage and commit in small, logical steps
git add src/invoice.ts
git commit -m "Round invoice totals to whole cents"
# Publish the branch, then open a pull request on your host
git push -u origin fix/invoice-rounding
# If main moved while you worked, bring your branch up to date
git fetch origin
git rebase origin/main # or: git merge origin/main
git push --force-with-lease # needed only after a rebaseHow to undo common mistakes
Pick the undo command by where the mistake is and whether you have pushed it. Amend and reset rewrite history, which is fine for commits only you have. For commits already pushed to a shared branch, use revert, which adds a new commit that reverses the old one.
Edits you never committed or stashed usually cannot be recovered once discarded. If you might need them, run git stash first, which saves them so you can restore them later.
Committed work is much harder to lose. The reflog records where HEAD has been in your clone, and by default its entries for lost commits last at least 30 days. You can find a commit there after a bad reset or rebase and put a branch on it.
# Discard uncommitted edits to a file (they cannot be recovered)
git restore src/app.ts
# Unstage a file but keep the edits
git restore --staged src/app.ts
# Fix the last commit's message or add a forgotten file (before pushing)
git add forgotten-file.ts
git commit --amend
# Undo the last commit but keep its changes staged
git reset --soft HEAD~1
# Throw away the last commit and its changes (destructive)
git reset --hard HEAD~1
# Undo a pushed commit by adding a new commit that reverses it
git revert a1b2c3d
# Find a lost commit and put a branch on it
git reflog
git branch recovered HEAD@{2}