Skip to main content

Git

Git is a distributed version control system. The sections below are a working reference; the everyday commands come first, followed by setup and less-common scenarios.

Everyday commands

git status
git switch BRANCH_NAME # modern replacement for `git checkout BRANCH_NAME`
git switch -c BRANCH_NAME # create and switch in one step
git log --oneline --graph --decorate -20
git diff # unstaged changes
git diff --staged # what a commit would contain right now

Sync a fork with its upstream

Add the upstream remote once, then repeat the fetch/merge whenever the fork falls behind.

# One-time setup
git remote add upstream UPSTREAM_URL
git remote -v

# Every time you need to catch up
git fetch upstream --tags
git switch main
git merge upstream/main
git push origin main

Two things to watch for:

  • --tags (plural) is the correct flag; --tag is not a git fetch option and the command fails.
  • The upstream default branch is not always main. Older repositories still use master, in which case merge upstream/master. Confirm with git remote show upstream and read the HEAD branch line.

Prefer git rebase upstream/main over merge when the fork has local commits that have not been pushed anywhere yet — it keeps the history linear. Never rebase commits that others have already pulled.

Branch from a tag

Useful when a release needs a patch but main has already moved on.

git fetch --tags
git tag # list tags; `git tag -l "v1.*"` to filter
git switch -c BRANCH_NAME TAG_NAME # branch based on the tag
git push -u origin BRANCH_NAME # -u records the upstream for later `git push`

Delete a branch

git branch -d BRANCH_NAME # refuses if the branch is not merged
git branch -D BRANCH_NAME # force delete, discards unmerged commits
git push origin -d BRANCH_NAME # delete the remote branch
git fetch --prune # drop remote-tracking refs that no longer exist

git branch -d only checks whether the branch is merged into the branch you are currently on, so switch to main first or the check gives a false negative.

Undo

git restore FILE # discard unstaged changes to a file
git restore --staged FILE # unstage but keep the edit
git commit --amend # rewrite the last commit (only if unpushed)
git revert COMMIT # new commit that undoes COMMIT; safe on shared branches
git reset --hard COMMIT # destructive: moves the branch and discards changes

git reflog lists where HEAD has pointed recently and is the way back from a bad reset or rebase. Entries expire after 90 days by default.

Stash work in progress

git stash push -m "MESSAGE" # add -u to include untracked files
git stash list
git stash pop # apply the newest entry and drop it
git stash apply stash@{1} # apply an older entry and keep it

Setup and config

Enable ssh-agent if your private key has a passphrase.

Reusable variables for the commands below:

USER_EMAIL="[email protected]"
USER_NAME="User Name"
SIGNING_KEY="0x7EF6B94D09F6AAAA"
BRANCH_NAME="next-version"
GIT_LOG_MESSAGE="New commit."
GIT_REPO_URL="https://github.com/user/repo.git"

Scope: --system (root), --global (user), --local (repo).

# Identity and signing key (local scope)
git config --local user.email $USER_EMAIL
git config --local user.name $USER_NAME
git config --local user.signingkey $SIGNING_KEY

# Credential storage
git config --local credential.helper store # save the password
git config --local --unset credential.helper # clear it

# Ignore file-mode (chmod) changes
git config --global core.fileMode false

# Show config
git config --local --list

Merge strategies

# Octopus merge: several branches on top of the current one
git merge fixes enhancements

# Keep the current branch's content, discarding the other side
git merge -s ours obsolete

git merge --continue

Tags

# -a annotated, -s GPG-signed (default key), -u GPG-signed with a specific key
git tag -u $SIGNING_KEY -s v1.0 -m $GIT_LOG_MESSAGE

git push origin --tags # push tags to the remote
git tag -l # list tags
git checkout TAG_NAME # check out a tag
git describe --tags # the current tag

Convert a lightweight tag to an annotated one by force-recreating it, then force-push (required since Git 1.8.2):

git tag -a -f <tagname> <tagname>
git push --force origin <tagname>

Commit message conventions

Prefixes: fix, add, change, refactor, remove, revert, merge, update, hotfix, disable, upgrade.

Rewrite history

git commit --amend -m "New message" # last commit only, if unpushed
git rebase -i COMMIT_HASH # interactive rebase from a commit
git rebase -i --root # include the very first commit

Interactive-rebase actions:

  • pick — keep the commit.
  • reword — keep the commit, edit its message.
  • edit — stop at the commit to amend it (including files).
  • squash — merge into the previous commit, keeping both messages.
  • fixup — merge into the previous commit, discarding this message.
  • exec — run a shell command.
  • drop — discard the commit.

Change the author across a range of commits:

git rebase -i HEAD~12
# change `pick` to `edit` on each commit, then, for each:
git commit --amend --author="Rojar Smith <[email protected]>"
git rebase --continue

Repository statistics

Contribution and volume figures pulled out of the history. Everything here is read-only, so it is safe to run on any clone.

These pipelines use POSIX tools (sort, uniq, awk, wc). On Windows, run them from Git Bash — they will not work in cmd.exe or PowerShell.

Lines changed per author

Walks the unique author names, then sums the --numstat columns for each one.

git log --format='%aN' | sort -u | while read name; do echo -en "$name\t"; git log --author="$name" --pretty=tformat: --numstat | awk '{ add += $1; subs += $2; loc += $1 - $2 } END { printf "added lines: %s, removed lines: %s, total lines: %s\n", add, subs, loc }' -; done

--numstat prints added removed path per file, --pretty=tformat: suppresses the commit headers so only those rows remain, and awk accumulates the two columns. The third figure is added minus removed — a net total, not the number of lines that author owns today.

Lines changed across the repository

The same sum without the per-author loop.

git log --pretty=tformat: --numstat | awk '{ add += $1; subs += $2; loc += $1 - $2 } END { printf "added lines: %s, removed lines: %s, total lines: %s\n", add, subs, loc }' -

Top committers

git log --pretty='%aN' | sort | uniq -c | sort -k1 -n -r | head -n 5

Git has this built in, and it is both shorter and faster:

git shortlog -sn HEAD # commits per author, descending
git shortlog -sne HEAD # include the email address
git shortlog -sn --no-merges HEAD # ignore merge commits

Number of authors

git log --pretty='%aN' | sort -u | wc -l

# Built-in equivalent
git shortlog -sn HEAD | wc -l

Number of commits

git log --oneline | wc -l

# Faster: counts revisions without formatting or piping
git rev-list --count HEAD
git rev-list --count --no-merges HEAD
git rev-list --count HEAD --since="2025-01-01"

Narrowing the range

Raw totals over a whole history are rarely the interesting number. Restrict by date, and exclude generated or vendored files so they do not dominate:

# One quarter only. %d rather than %s so an empty range prints 0, not blank.
git log --since="2025-01-01" --until="2025-04-01" --pretty=tformat: --numstat | awk '{ add += $1; subs += $2 } END { printf "added: %d, removed: %d\n", add, subs }' -

# Skip lock files and generated output
git log --pretty=tformat: --numstat -- . ':(exclude)package-lock.json' ':(exclude)**/*.g.dart'

Reading the numbers

Every figure above has a caveat, and all of them matter before quoting one at someone:

  • Net lines are not lines owned. A developer who refactors 500 lines into 200 scores negatively. To see who owns the code as it stands now, use git blame — for example git blame --line-porcelain FILE | grep "^author " — not the log.
  • Binary files count as zero. --numstat prints - instead of a number for them, and awk evaluates - as 0. This repository has such rows, so the totals silently exclude every image and PDF.
  • Merge commits contribute nothing. git log shows no diff for a merge unless asked, so merge-heavy histories under-report.
  • --author is a substring match. "Chris" also matches "Christopher", which double-counts. It matches against the whole Name <email> string, so --author="^Rojar Smith$" finds nothing — the email follows the name. Anchor the end on the opening bracket instead:
git log --author="^Rojar Smith <" --oneline
  • Empty output means no commits matched, not zero lines. With printf "%s" an unset awk variable prints as an empty string; a date range that selects nothing therefore prints added lines: , removed lines: , rather than zeros. Use %d if a script has to parse the result.
  • Duplicate identities split the totals. The same person committing from two machines with different user.email values appears twice. %aN and git shortlog both honour .mailmap, so map the aliases once at the repository root:
  • git shortlog reads stdin when it has no revision argument. In a script, a cron job, or CI, plain git shortlog -sn produces empty output rather than an error. Always pass a revision, such as HEAD.
  • Lines of code is a poor measure of contribution. It rewards verbosity and penalises deletion, review, and design. These commands are useful for sizing a codebase and spotting where churn is concentrated; they are not a performance review.

Adapted from Git: from beginner to professional.

Scenarios

Initialize over existing files

git init
git add -A -f # -f: add even gitignored files
git commit -m "Initial commit"
git remote add origin $GIT_REPO_URL
git push -f origin main

Discard all local changes

git reset --hard
git clean -fdx

Revert a single file to an earlier version

git log
git checkout <commit> path/to/file

Pull from an upstream repository

git remote add upstream https://github.com/eclipse/hawkbit.git
git remote -v
git pull upstream master --tags

error: pathspec 'XXX' did not match any files

git add -A -f

Remote host identification has changed

ssh-keygen -f "/root/.ssh/known_hosts" -R "github.com"

Multiple SSH keys

# Generate a deploy key
ssh-keygen -t rsa -C 'root@vm5' -f ~/.ssh/id_rsa_github

~/.ssh/config:

Host github-rs-company
HostName github.com
IdentityFile ~/.ssh/id_rsa_rs_company
git clone git@github-rs-company:rs-company/company-project.git

Remove the last commit

git reset --hard HEAD^
git push origin -f

Compare commits between tags

git log --pretty=oneline tagA...tagB # symmetric difference (three dots)
git log --pretty=oneline tagA..tagB # reachable from tagB but not tagA (two dots)
git log --pretty=oneline ^tagA tagB # equivalent to the two-dot form

Apply a patch or diff

# The file must be Unix (LF), UTF-8, with a trailing newline
git diff > modify.diff
git apply --check path/to/xxx.patch
git apply path/to/xxx.patch

Cherry-pick commits onto an upstream branch

git fetch --all
git checkout -b new-branch-name upstream/master
git cherry-pick commit-hash1
git cherry-pick commit-hash2
git push -u origin new-branch-name

Clone with username and password

git pull https://user:[email protected]/name/repo.git master

Change the remote URL

git remote set-url origin https://github.com/USERNAME/OTHERREPOSITORY.git

Move recent commits to a new branch

git branch my-branch
git reset --hard HEAD~3 # or: git reset --keep HEAD~3
git checkout my-branch

Sparse checkout

mkdir folder && cd folder
git init
git remote add origin [email protected]:rojarsmith/bitdove-hawkbit.git
git config core.sparseCheckout true
echo "docs/*" >> .git/info/sparse-checkout
git pull origin bitdove