A Computing Reference, Tested on Git 2.43
Git & GitHub
How Git really works, the commands you use every day, how to undo almost anything safely, and how to run a project on GitHub with pull requests, protection and Actions
Contents
01 How Git Thinks 02 Installing, Configuring and Authenticating 03 Everyday Commands 04 Branching, Merging and Rebasing 05 Working with Remotes 06 Undoing Things Safely 07 Inspecting History and Debugging 08 GitHub 09 Workflows and Conventions 10 Beyond the Basics 11 Worked Example: Maintaining a Static Website 12 Troubleshooting 13 Glossary 14 AppendicesMost people learn Git as a list of incantations, and most Git trouble comes from that. Once you can picture what a command does to a small number of simple structures, the commands stop being magic and the error messages start making sense. This chapter builds that picture. Everything later in the handbook refers back to it.
Tested version
Every command and every piece of output quoted in this handbook was run on Git 2.43.0 (the version Ubuntu 24.04 LTS ships) in throwaway repositories, with remotes simulated by local bare repositories. Where a command or option needs a newer Git than some people will have, the minimum version is labelled, for example (Git 2.23+). The current release when this was written was 2.56.0; later versions sometimes reword messages slightly, but the behaviour described here has not changed. Commit hashes in the examples will differ on your machine.
1.1 Git and GitHub Are Different Things
Git is a version control program that runs on your own computer. It records the history of a folder of files, lets you work on several lines of development at once, and lets you exchange that history with other copies of the same project. It needs no server and no account. A repository on your laptop is a complete repository, with its entire history.
GitHub is a website owned by Microsoft that hosts Git repositories and adds things Git does not have: issues, pull requests and code review, permissions, automation (GitHub Actions), web hosting (GitHub Pages) and security scanning. GitLab, Bitbucket and Codeberg offer similar services. Everything in Parts I and II of this handbook is plain Git and works with any host; Part III is specific to GitHub.
1.2 Snapshots, Not Differences
Older version control systems stored each file as an original plus a list of changes. Git does not think that way. Each commit records a snapshot of every tracked file as it was at that moment. If a file did not change, the new snapshot simply points at the copy Git already has, so unchanged files cost almost nothing.
When you ask Git for a "diff", it is computed on the spot by comparing two snapshots. This explains several things that otherwise seem odd: why switching branches is fast (Git just writes out a different snapshot), why renames are detected after the fact rather than recorded, and why you can compare any two commits directly, however far apart.
For the curious
On disk, Git does eventually compress similar objects against each other inside "packfiles", so storage is delta-based at the lowest level. That is an optimisation hidden from you. Every command still behaves as if each commit were a full snapshot.
1.3 The Three Areas
At any moment your project exists in three places, and almost every everyday command moves content between them.
- The working tree is the ordinary folder of files you edit with your editor.
- The index, also called the staging area, is a proposed next snapshot. It starts as a copy of the last commit.
git addcopies a file's current content from the working tree into it. - The repository is the
.gitfolder at the top of the project. It holds every commit ever made, plus branches, tags and settings.git committurns the index into a permanent snapshot here.
The index is what makes Git feel fussy to beginners, and it is also what makes it powerful. You can edit ten files and then commit them as three tidy, separate commits, each containing only the changes that belong together. git status always tells you which area each change is in.
A file in the working tree is either tracked (Git has it in the index and will notice changes) or untracked (Git sees it but ignores it until you git add it). Untracked files are never included in a commit by accident, and several commands, such as a plain git stash, leave them alone.
1.4 The Four Object Types
Inside .git/objects, Git stores just four kinds of object. Each is named by a hash of its contents (a 40-character SHA-1 hexadecimal string by default), which is why two identical files are stored only once and why history cannot be altered without every later hash changing.
| Object | What it holds |
|---|---|
| blob | The contents of one file. No name, no permissions, just bytes. |
| tree | A directory listing: names, file modes, and the hashes of the blobs and sub-trees inside it. |
| commit | A pointer to one top-level tree (the snapshot), the hash of its parent commit or commits, the author and committer with timestamps, and the message. |
| tag | An annotated tag: a named, dated, optionally signed label pointing at another object, usually a commit. |
You can look at them directly. In a fresh repository with one commit containing index.html and css/site.css:
$ git cat-file -p HEAD
tree 5e8096bbaf7d595b75110c7bb4a32a09b9031d06
author Ada Example <ada@example.com> 1791142431 +0000
committer Ada Example <ada@example.com> 1791142431 +0000
First page
$ git cat-file -p HEAD^{tree}
040000 tree a34baa5134bac49a4daef6dcd8894080ff91269e css
100644 blob 8a134bf277603b24d96de96a87e8f66784e46874 index.htmlThat repository held five objects (one commit, two trees, two blobs). Adding a second commit containing an exact copy of index.html under a new name added only two more objects, a new tree and a new commit: the copy reused the existing blob. You will rarely need cat-file, but seeing it once makes the rest of Git much less mysterious.
Version note
Git can also create repositories using SHA-256 object names (git init --object-format=sha256), and the Git project's published plans for Git 3.0 make SHA-256 the default for new repositories. The Git project itself notes that this depends on hosting services and other tools supporting the format, so check your host before creating one, and stay with the default unless you know you need SHA-256.
1.5 Commits Form a Graph; Branches Are Pointers
Because each commit records its parent, commits link together into a chain going backwards in time. When two lines of work split and later join, the chain becomes a graph: a directed acyclic graph, or DAG. "Directed" because links point from child to parent; "acyclic" because a commit can never be its own ancestor.
A branch is not a copy of anything. It is a tiny file containing one commit hash, for example .git/refs/heads/main (or a line in .git/packed-refs). When you commit on a branch, Git creates the new commit with the old tip as its parent and then moves the branch pointer forward to it. Creating a branch is therefore instant and costs a few dozen bytes.
HEAD is how Git knows where you are. Normally it does not hold a hash at all but the name of a branch:
$ cat .git/HEAD
ref: refs/heads/mainSo "the current commit" means "whatever commit the current branch points at", and committing moves that branch.
Two consequences are worth fixing in your mind now. First, a commit is "on" a branch only in the sense that it can be reached by following parent links back from the branch's tip; in Diagram 2, commits A and B are on both main and feature. Second, deleting a branch deletes only the label. The commits stay in the repository until Git's housekeeping eventually removes ones nothing refers to, and until then they can be recovered (Chapter 6).
Git gives you shorthand for walking the graph. HEAD~1 (or HEAD~) is the parent of the current commit, HEAD~3 is three generations back following first parents, and HEAD^2 is the second parent of a merge commit. A short unique prefix of a hash, such as 8e37df4, also works anywhere a commit is expected.
1.6 Detached HEAD
If you switch to a commit rather than a branch, for example to look at an old version, HEAD holds the commit hash directly. Git calls this detached HEAD. Running git checkout HEAD~1 in Git 2.43 prints:
Note: switching to 'HEAD~1'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>Nothing is wrong. Detached HEAD is the normal state during a bisect or a rebase, and a perfectly good way to inspect history. The only trap is committing while detached and then switching away, because no branch points at the new commits. Git warns you when you leave:
Warning: you are leaving 1 commit behind, not connected to
any of your branches:
581fa9e Experiment
If you want to keep it by creating a new branch, this may be a good time
to do so with:
git branch <new-branch-name> 581fa9eTake the advice and run the suggested git branch command if you want the work. If you have already lost the hash from the screen, git reflog will find it (Section 6.5).
1.7 Remotes and Remote-Tracking Branches
A remote is a named URL for another copy of the repository, usually on GitHub. When you clone, Git names the source origin. Your repository and the remote are peers: neither updates the other unless you run a command that talks over the network, and only four common ones do (clone, fetch, pull and push).
To remember what the remote looked like the last time you spoke to it, Git keeps remote-tracking branches with names like origin/main. They are read-only bookmarks that only fetch (and therefore pull) and successful pushes move. Your own main and origin/main are separate pointers that can, and often do, point at different commits.
This is why git status can say "Your branch is up to date with 'origin/main'" while a colleague's newer commits sit on GitHub: the comparison is with your last-known copy, not with GitHub itself. Run git fetch first when you need the real answer.
Git needs very little setting up, but the few settings it does need are worth getting right on day one: your identity is baked permanently into every commit you make, and a wrong line-ending setting can make every file in a project appear changed.
2.1 Installing Git
winget install --id Git.Git -e --source winget
The installer asks several questions. The defaults are sensible; the two that matter are the default editor (pick one you know, rather than Vim if you have never used it) and the line-ending choice, which should stay at "Checkout Windows-style, commit Unix-style line endings" (this sets core.autocrlf=true).
xcode-select --install # Apple's Git, with the command line tools
brew install git # current Git, via Homebrew
sudo apt update && sudo apt install git
# Optional: newest stable Git on Ubuntu
sudo add-apt-repository ppa:git-core/ppa
sudo apt update && sudo apt install git
Check the result with git --version. Anything from 2.23 onwards has every everyday command in this handbook; a few newer options are labelled where they appear.
2.2 Configuration Levels
Git reads settings from several files, and a more specific file overrides a more general one:
| Level | Flag | Location | Applies to |
|---|---|---|---|
| System | --system | /etc/gitconfig on Linux and macOS; inside the Git installation folder on Windows | Every user on the machine |
| Global | --global | ~/.gitconfig (or ~/.config/git/config) | You, in every repository |
| Local | --local (the default when writing) | .git/config in the repository | That repository only |
.git/config.git config --global user.name "Ada Example"
git config user.email # print the value in effect here
git config --list --show-origin # every setting and the file it came from
git config --show-scope --get user.name # which level won
git config --global --unset pull.rebase # remove a setting
git config --global --edit # open the file in your editor
The local level is useful when one project needs a different identity, such as a work email address in a work repository: run the user.email command inside that repository without --global.
2.3 Essential Settings
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main
git config --global core.editor "code --wait"
git config --global pull.rebase true
git config --global merge.conflictStyle zdiff3- user.name and user.email are written into every commit. Git refuses to commit without them ("Author identity unknown ... Please tell me who you are."). If you do not want your personal address public on GitHub, use the private
noreplyaddress GitHub shows under Settings, Emails, which has the formID+USERNAME@users.noreply.github.com, after ticking "Keep my email address private". - init.defaultBranch (Git 2.28+) names the first branch of new repositories. Without it, Git 2.43 still uses
masterand prints a long hint every time you rungit init. GitHub names the default branchmainin new repositories, and the Git project's published plans for Git 3.0 change Git's own default tomain, so settingmainnow avoids a mismatch. - core.editor is the program Git opens for commit messages and interactive rebases.
"code --wait"is VS Code (the--waitmatters: Git must wait until you close the tab).nanois the gentlest terminal choice. - pull.rebase decides what
git pulldoes when your branch and the remote's have both moved on.truereplays your local commits on top of the remote's, giving a straight-line history;falsemakes a merge commit. Since Git 2.27,git pullwarns if you have not chosen, and Git 2.43 refuses to pull divergent branches at all ("fatal: Need to specify how to reconcile divergent branches.") until you set this or pass--rebaseor--no-rebase. Section 5.2 explains the choice. - merge.conflictStyle zdiff3 (Git 2.35+) adds the original text to conflict markers, which makes most conflicts much easier to resolve (Section 4.3). On older Git use
diff3.
Two optional settings worth knowing: git config --global push.autoSetupRemote true makes the first git push of a new branch create and track the remote branch without needing -u (tested on 2.43; not present in very old versions), and aliases such as git config --global alias.lg "log --graph --oneline --decorate --all" save typing.
2.4 Line Endings on Windows
Windows text files traditionally end each line with two characters (carriage return and line feed, CRLF); Linux and macOS use one (LF). If people on different systems commit both styles, every line of a file can appear changed. Git can convert for you. GitHub's documentation recommends:
# Windows
git config --global core.autocrlf true
# macOS and Linux
git config --global core.autocrlf inputWith these settings Git stores LF in the repository and, on Windows, gives you CRLF in the working tree. When it is about to convert a file it tells you, which is the source of a warning many people find alarming but is harmless:
warning: in the working copy of 'unix.txt', LF will be replaced by CRLF the next time Git touches itPersonal settings only help if everyone sets them. The robust answer is a .gitattributes file committed at the top of the repository, which applies to every clone regardless of personal configuration:
# .gitattributes
* text=auto # let Git detect text files and normalise them to LF
*.sh text eol=lf # shell scripts must keep LF, even on Windows
*.bat text eol=crlf # Windows batch files need CRLF
*.png binary # never convert or diff these
*.jpg binaryAfter adding .gitattributes to an existing project, run git add --renormalize . and commit, so the files already in the repository are converted once. git ls-files --eol shows the line endings Git sees in the index (i/) and working tree (w/) for every file.
2.5 Connecting to GitHub with SSH
GitHub accepts two ways of authenticating Git: SSH keys and HTTPS. Pick one per machine; you can tell which a repository uses from its remote URL (git remote -v): git@github.com:owner/repo.git is SSH and https://github.com/owner/repo.git is HTTPS.
SSH uses a key pair. The private key never leaves your computer; you give GitHub the public half. GitHub's current instructions use the Ed25519 algorithm:
- Generate the key.
ssh-keygen -t ed25519 -C "you@example.com". Accept the default file location and choose a passphrase; the agent in step 2 means you will rarely have to type it. - Start the agent and add the key. On Linux:
eval "$(ssh-agent -s)"thenssh-add ~/.ssh/id_ed25519. On macOS, GitHub recommends addingAddKeysToAgent yes,UseKeychain yesandIdentityFile ~/.ssh/id_ed25519under aHost github.comentry in~/.ssh/config, thenssh-add --apple-use-keychain ~/.ssh/id_ed25519. On Windows, in an administrator PowerShell window runGet-Service -Name ssh-agent | Set-Service -StartupType ManualandStart-Service ssh-agent, then in a normal windowssh-add c:/Users/YOU/.ssh/id_ed25519. - Give GitHub the public key. Copy the contents of
~/.ssh/id_ed25519.pub(the file ending.pub, never the other one) and paste it into GitHub under Settings, SSH and GPG keys, New SSH key. - Test.
ssh -T git@github.com. The first time, check the host fingerprint against the one GitHub publishes in its documentation before typing yes. Success looks like:Hi USERNAME! You've successfully authenticated, but GitHub does not provide shell access.
id_ed25519, no extension) is equivalent to a password that never expires. Never paste it anywhere, never commit it, and never copy it to a shared machine. If it leaks, delete the public key from your GitHub settings straight away and generate a new pair.2.6 HTTPS, Tokens and Credential Managers
Over HTTPS, GitHub no longer accepts your account password for Git operations; its documentation states that password-based authentication for Git has been removed. When Git asks for a password, you must either supply a personal access token or let a credential helper sign you in through the browser. The second is far more pleasant:
- Git Credential Manager (GCM) is included with Git for Windows and is selected by default in its installer. On macOS install it with
brew install --cask git-credential-manager. On Linux it is available as a .deb, a tarball or a .NET tool, and needs a credential store configuring afterwards; follow the GCM install guide. The first push opens a browser window to sign in, and GCM stores the result securely. - GitHub CLI. Run
gh auth login, choose HTTPS when asked for the preferred protocol, and answer yes when asked whether to authenticate Git with your GitHub credentials.gh auth setup-gitdoes the Git part on its own if you skipped it.
If you do need a raw token (for a script, say), GitHub offers fine-grained personal access tokens and older classic tokens, and recommends fine-grained ones wherever possible because each can be limited to particular repositories and permissions. Give every token an expiry date. GitHub also removes tokens that have not been used for a year.
| SSH | HTTPS | |
|---|---|---|
| Setup | Generate a key once per machine, upload the public half | Install a credential manager or GitHub CLI, sign in through the browser |
| Firewalls | Uses port 22, which some corporate and public networks block | Uses port 443, which is open almost everywhere |
| Scope | A key grants access to everything your account can reach | Tokens can be limited to specific repositories and permissions |
| Bonus | The same key can sign commits (Section 10.5) | Works unchanged for GitHub CLI and API tools |
To switch an existing clone from one to the other, change its remote URL: git remote set-url origin git@github.com:owner/repo.git.
This chapter is the reference for the commands you will use every day, in the same format as the PowerShell and Ubuntu handbooks. The rhythm of ordinary work is short: edit, status, diff, add, commit, repeat, with log when you need to look back.
3.1 Starting a Repository
.git folder. Nothing is tracked until you add it.git init # in the current folder
git init my-site # creates my-site/ and initialises it
git init -b main # name the first branch (Git 2.28+)
origin to point at the source, and checks out the default branch.git clone git@github.com:owner/repo.git # SSH
git clone https://github.com/owner/repo.git # HTTPS
git clone https://github.com/owner/repo.git site # into a folder called site
git clone --depth 1 https://github.com/owner/repo.git # latest snapshot only
Key flags: --depth N (shallow clone, faster but with truncated history), -b branch (check out a different branch), --recurse-submodules (Section 10.1).
3.2 Seeing What Has Changed
$ git status
On branch main
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: index.html
no changes added to commit (use "git add" and/or "git commit -a")
$ git status -s
MM index.html
?? draft.html
Short format: the first column is the index, the second the working tree. M modified, A added, D deleted, R renamed, ?? untracked, UU unresolved conflict. MM means part of the change is staged and a further edit is not. -sb adds the branch line.
git diff # working tree vs index: what you have NOT staged
git diff --staged # index vs last commit: what WILL be committed
git diff HEAD # working tree vs last commit: everything
git diff main feature # tip of main vs tip of feature
git diff main...feature # what feature changed since it branched from main
git diff HEAD~2 HEAD --stat # summary of files changed in the last two commits
git diff --name-only HEAD~2 # just the file names
git diff --word-diff # changes within lines; good for prose and HTML
--cached is an older synonym for --staged. A plain git diff that shows nothing does not mean there are no changes; it may mean they are all staged.
3.3 Staging and Committing
git add index.html # one file
git add css/ # a folder
git add . # everything in and below the current folder
git add -p # choose hunk by hunk (patch mode)
git add -u # only files Git already tracks, including deletions
Patch mode (git add -p) is the single most useful habit for clean history. Git shows each changed region ("hunk") and asks what to do with it:
@@ -1,4 +1,5 @@
<h1>Hello</h1>
-<p>One</p>
+<p>One!</p>
<p>Two</p>
<p>Three</p>
+<p>Four</p>
(1/1) Stage this hunk [y,n,q,a,d,s,e,?]? s
Split into 2 hunks.Answer y to stage the hunk, n to skip it, s to split it into smaller hunks where unchanged lines separate the edits, e to edit the hunk by hand, q to stop, and ? for the full list. Afterwards, git diff --staged shows exactly what you have chosen.
-m it opens your editor for the message.git commit # write the message in your editor
git commit -m "Add contact page" # one-line message
git commit -v # show the diff in the editor while you write
git commit -a -m "Fix typo" # stage every tracked, modified file first
git commit --amend # replace the last commit (Section 6.2)
Caution: -a stages all modified tracked files, including ones you did not mean to include, and never picks up new untracked files.
Writing good commit messages
A commit message is a note to whoever reads the history next, often you in a year's time. The widely followed convention, which Git's own tools assume, is:
- A summary line of about 50 characters, written as a command ("Add contact page", not "Added" or "Adds"), with no full stop. Many tools truncate long summaries.
- A blank line, then a body wrapped at about 72 characters that explains why the change was made and anything surprising about it. The diff already shows what changed.
- One logical change per commit. "Fix menu on phones and update footer year" is two commits.
Stop the contents drawer covering the search box
On phones narrower than 380px the drawer opened over the search field,
so typing a query hid the results. Give the drawer a top offset equal
to the header height instead of zero.
Closes #14Section 9.4 describes Conventional Commits, a stricter format some projects use.
3.4 Reading History
git log # full details
git log --oneline # one line per commit
git log --oneline --graph --decorate --all # the whole graph with branch labels
git log -p # include each commit's diff
git log --stat # include files changed per commit
git log -5 # last five commits
git log main..feature # commits on feature that main lacks
git log --author="Ada" --since="2 weeks ago"
git log -S"Four" # commits that added or removed this text
git log -G"colou?r" # commits whose diff matches a regex
git log --follow docs.txt # one file's history, across renames
git log --pretty=format:'%h %an %ar %s' # custom format
Useful placeholders: %h short hash, %an author name, %ad author date (with --date=short for YYYY-MM-DD), %ar relative date, %s subject. The -S "pickaxe" is the fastest way to answer "when did this line appear or disappear?"
The graph view after a merge looks like this (from a test repository):
$ git log --oneline --graph
* 2e1c4a1 Merge branch 'add-contact'
|\
| * a10e4b7 Add contact page
* | 09a4f0e Add stylesheet
|/
* c8135fd Add about page
* f7c948a Emphasise first paragraphgit show # the current commit
git show HEAD~1 --stat # previous commit, files only
git show v1.0 # a tag and the commit it points at
git show HEAD~3:index.html # the file as it was three commits ago
3.5 Switching Branches and Restoring Files
For years, git checkout did two unrelated jobs: moving between branches and overwriting files. Git 2.23 split these into git switch and git restore, which are clearer and harder to misuse. checkout still works and you will see it in older tutorials.
git switch feature # go to an existing branch
git switch -c new-idea # create a branch here and switch to it
git switch - # back to the previous branch
git switch --detach v1.0 # look at a tag without a branch (detached HEAD)
git switch topic # if only origin/topic exists, creates a tracking branch
Older equivalents: git checkout feature, git checkout -b new-idea.
git restore index.html # discard unstaged edits (copy from index)
git restore --staged index.html # unstage (copy from HEAD into index)
git restore --staged --worktree index.html # both: back to the last commit
git restore --source=HEAD~2 index.html # the version from two commits ago
git restore -p index.html # discard selected hunks only
Older equivalents: git checkout <file> discards edits; git reset <file> unstages.
git restore <file> (without --staged) throws away uncommitted edits to that file permanently. Git never had them, so neither the reflog nor anything else can bring them back. If in doubt, git stash first.3.6 Removing, Moving and Cleaning
mv followed by git add -A gives the same result.git mv f.txt docs.txt # status shows: R f.txt -> docs.txt
git rm old.html # delete from disk and index
git rm --cached debug.log # stop tracking but keep the file on disk
git rm -r --cached build/ # the same for a folder
--cached is how you fix "I committed a file that should have been ignored": remove it from the index, add it to .gitignore, commit. It stays in older commits; see Section 8.9 if it was a secret.
git clean -n # list what would be removed (dry run)
git clean -nd # include untracked folders
git clean -fd # actually delete untracked files and folders
git clean -ndX # dry run of removing only ignored files (build output etc.)
Caution: in testing, git clean -fd deleted a brand-new .gitignore that had not been committed yet, because it was untracked. Deleted untracked files are gone for good.
3.7 Ignoring Files with .gitignore
A .gitignore file lists patterns for untracked files Git should not offer to add: build output, logs, editor clutter, and above all secrets such as .env. Commit it so everyone shares it. Ignoring only affects untracked files; a file that is already tracked stays tracked until you git rm --cached it.
| Pattern | Matches |
|---|---|
*.log | Any file ending .log, in any folder |
build/ | A folder called build anywhere (trailing slash means folders only) |
/todo.txt | Only todo.txt at the top level (leading slash anchors to the .gitignore's folder) |
docs/*.pdf | PDFs directly inside docs, not in its sub-folders |
docs/**/*.pdf | PDFs anywhere under docs |
!important.log | Re-include a file an earlier pattern excluded |
# comment | Ignored line |
One subtlety: you cannot re-include a file if its parent folder is excluded, because Git never looks inside an excluded folder. Exclude the folder's contents instead (logs/*) and then re-include (!logs/keep/).
$ git check-ignore -v debug.log build/a.js .env
.gitignore:2:*.log debug.log
.gitignore:1:build/ build/a.js
.gitignore:4:.env .env
For personal clutter that has nothing to do with the project (such as macOS .DS_Store files or your editor's settings), use a global ignore file rather than every project's .gitignore: Git reads ~/.config/git/ignore by default. Appendix 14.3 has starter templates.
Branches let you work on something without disturbing anything else, and then bring the work back. Because a branch is only a pointer (Section 1.5), they are cheap enough to make for every task, however small. The skill lies in bringing work back together cleanly.
4.1 Working with Branches
git switch -c for that.git branch # local branches; * marks the current one
git branch -vv # with last commit and upstream status
git branch -a # include remote-tracking branches
git branch fix-footer # create at the current commit
git branch fix-footer v1.0 # create at a tag or any commit
git branch -m old new # rename
git branch -d fix-footer # delete, only if merged
git branch -D fix-footer # delete even if not merged
git branch --merged main # branches already contained in main
-d refuses with "error: the branch 'spike' is not fully merged." when deleting would leave commits unreachable. That is a safety net, not an obstacle; read Section 6.5 before overriding it with -D.
Name branches after what they do: fix-drawer-overlap, add-git-guide. Some teams prefix the type (feature/, fix/) or an issue number; Git treats the slash as an ordinary character.
4.2 Fast-Forward and Three-Way Merges
git merge other brings the commits of other into the current branch. What it does depends on the shape of the graph.
Fast-forward. If the current branch has not moved since other branched off, there is nothing to combine: Git simply slides the branch pointer forward to other's tip. No new commit is made.
$ git switch main
$ git merge add-about
Updating f7c948a..c8135fd
Fast-forward
about.html | 1 +
1 file changed, 1 insertion(+)Three-way merge. If both branches have new commits, Git finds their common ancestor (the merge base), works out what each side changed relative to it, combines the two sets of changes, and records the result as a merge commit with two parents.
$ git merge --no-edit add-contact
Merge made by the 'ort' strategy.
contact.html | 1 +
1 file changed, 1 insertion(+)The "ort" merge strategy has been the default since Git 2.34; older versions say "recursive". Two flags change the default behaviour: --no-ff forces a merge commit even when a fast-forward was possible (some teams like every feature to appear as a visible merge), and --ff-only refuses to do anything except fast-forward, which is a safe way to update a branch you never commit to directly.
4.3 Resolving a Merge Conflict, Step by Step
A conflict happens when both sides changed the same lines (or one side edited a file the other deleted). Git cannot know which version you want, so it stops and asks. A conflict is a normal event, not an error, and nothing is lost.
- Read the message. Git names the files:
Auto-merging index.html CONFLICT (content): Merge conflict in index.html Automatic merge failed; fix conflicts and then commit the result. - Run
git status. Conflicted files are listed under "Unmerged paths" as "both modified". Files that merged cleanly are already staged. - Open each conflicted file. Git has written both versions into it between markers. With
merge.conflictStyle zdiff3(Section 2.3) you also see the original, which shows what each side actually changed:The top section is your current branch (HEAD), the middle is the common ancestor, and the bottom is the branch being merged in. Here one side changed "Hello" to "Good morning" and the other to "Welcome".<<<<<<< HEAD <h1>Good morning</h1> ||||||| 2e1c4a1 <h1>Hello</h1> ======= <h1>Welcome</h1> >>>>>>> retitle - Edit the file into the form you want and delete all the marker lines. The answer may be one side, the other, or a combination: here,
<h1>Welcome, good morning</h1>. - Mark it resolved with
git add index.html. Repeat steps 3 to 5 for every conflicted file. - Check and finish.
git diff --stagedto review, thengit commit(Git supplies the merge message). For a conflict during a rebase, cherry-pick or revert, the finishing command isgit rebase --continue,git cherry-pick --continueorgit revert --continueinstead.
If it all goes wrong, git merge --abort returns everything to exactly how it was before the merge. git checkout --conflict=merge index.html recreates the markers in one file if you have made a mess of resolving it. Editors such as VS Code highlight conflict blocks and offer "Accept Current", "Accept Incoming" and "Accept Both" buttons, which do the same editing for you.
<<<<<<< and >>>>>>> lines (git diff --check flags them in staged changes). Git will happily commit a file that still contains markers, and on a website they appear on the published page.4.4 Rebase, and the Golden Rule
git rebase main, run on a feature branch, takes the commits that are on your branch but not on main, and replays them one by one on top of main's current tip. The result looks as if you had started your work today. Because each replayed commit has a different parent, each is a new commit with a new hash; the originals are left behind, unreferenced.
$ git switch feature
$ git rebase main
Successfully rebased and updated refs/heads/feature.Neither approach is "right". Merging is honest and never rewrites anything; rebasing gives a tidier history that is easier to read and to bisect. Many teams rebase their own feature branches to keep them current, then merge them through a pull request.
If a commit conflicts while being replayed, the rebase pauses. Resolve it exactly as in Section 4.3, then git add the file and run git rebase --continue. git rebase --skip drops the troublesome commit, and git rebase --abort puts everything back as it was before you started.
4.5 Interactive Rebase: Editing Your Own History
git rebase -i opens a to-do list of commits in your editor and lets you reorder, combine, reword or drop them before they are replayed. It is the tool for turning a messy series of "wip" commits into a clean story before you share it.
$ git rebase -i HEAD~3
pick 91c61c6 Notes part 1
pick a304436 Notes part 2
pick d2c1188 Notes part 3
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
# commit's log message, ...
# d, drop <commit> = remove commitThe list is in oldest-first order, the reverse of git log. Change the word at the start of a line, save and close:
Change pick to | Effect |
|---|---|
reword | Keep the commit, stop to edit its message |
squash | Combine with the commit above; edit the combined message |
fixup | Combine with the commit above; silently keep the above's message |
edit | Stop after this commit so you can amend it or split it |
drop (or delete the line) | Remove the commit entirely |
| Move the line | Reorder commits |
In testing, changing the second line to fixup and the third to reword turned three commits into two: "Notes part 1" (now including part 2's changes) and a reworded "Write release notes".
Fixup commits and autosquash
When you notice a mistake in an earlier commit on your branch, commit the correction with --fixup, naming the commit it belongs to. Later, --autosquash moves it into place and marks it fixup for you:
git commit -a --fixup=00d1a1d # creates "fixup! Add notes intro"
git rebase -i --autosquash main # the to-do list arrives pre-arrangedFrom Git 2.44, --autosquash also works with a non-interactive git rebase.
4.6 Cherry-Pick
main now, before the feature is ready.git cherry-pick 03cf228 # one commit
git cherry-pick A..B # every commit after A up to and including B
git cherry-pick -x 03cf228 # add "(cherry picked from commit ...)" to the message
git cherry-pick --abort # give up during a conflict
The copy is a new commit with a new hash. If the original branch is merged later, Git usually notices the change is already present, but not always; prefer merging where you can, and cherry-pick for the occasional urgent fix.
4.7 Stash: Putting Work Aside
pop.git stash # save tracked changes
git stash push -u -m "draft page" # include untracked files, with a label
git stash list # stash@{0}: On main: draft page
git stash pop # reapply the newest and remove it from the stack
git stash apply stash@{1} # reapply a specific one and keep it
git stash show -p stash@{0} # see what is in it
git stash drop stash@{0} # discard one
git stash branch new-branch # make a branch from the stash's base and apply it
Watch out: a plain git stash does not include untracked (new) files; in testing a new draft.html stayed in the working tree. Use -u. Stashes are easy to forget about; for anything you will want tomorrow, a commit on a branch is safer.
If git stash pop hits a conflict, Git applies what it can, marks the conflicts and keeps the stash entry so nothing is lost. Resolve as in Section 4.3, then git stash drop once you are happy.
A remote is just another repository, and every exchange with it is one of two things: fetching its commits into yours, or pushing yours into it. Keep Diagram 3 in mind throughout this chapter.
5.1 Managing Remotes
git remote -v # names and URLs
git remote add origin git@github.com:me/site.git
git remote add upstream https://github.com/them/site.git
git remote set-url origin https://github.com/me/site.git
git remote rename upstream source
git remote remove source
git remote show origin # branches and tracking details
5.2 Fetch Versus Pull
origin/main and so on). It never changes your own branches or your working tree, so it is always safe.git fetch # from the current branch's remote, usually origin
git fetch upstream # from a named remote
git fetch --all # from every remote
git fetch --prune # also delete remote-tracking branches that no longer exist
After fetching, look before you integrate:
$ git fetch
From ../origin
729978f..1b05e38 main -> origin/main
$ git status
On branch main
Your branch and 'origin/main' have diverged,
and have 1 and 1 different commits each, respectively.
$ git log --oneline main..origin/main # what they have that you do not
$ git diff main...origin/main # what they changedgit fetch followed immediately by integrating the upstream branch into the current one, by merge or by rebase.git pull # uses your pull.rebase setting
git pull --rebase # replay your local commits on top of the remote's
git pull --no-rebase # merge the remote's commits into yours
git pull --ff-only # only move forward; refuse if you have local commits
If nobody else has pushed, either way simply fast-forwards. The setting only matters when both sides have new commits. A merge leaves a "Merge branch 'main' of github.com:..." commit in the history every time this happens, which clutters it with joins that carry no meaning. Rebasing your unpushed local commits on top of what you fetched avoids that, and does not break the golden rule because those commits have not been shared yet. That is why this handbook recommends pull.rebase true for most people.
Without a setting, Git 2.43 stops and asks you to choose:
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint: git config pull.rebase false # merge
hint: git config pull.rebase true # rebase
hint: git config pull.ff only # fast-forward only
...
fatal: Need to specify how to reconcile divergent branches.A rebasing pull refuses to start if you have any uncommitted changes ("error: cannot pull with rebase: You have unstaged changes."); a merging pull refuses only if they would be overwritten. Commit or stash first, or set rebase.autoStash true to have Git stash and restore them around the rebase automatically.
5.3 Push and Upstream Tracking
git push -u origin add-git-guide # first push of a new branch; sets upstream
git push # afterwards, plain push is enough
git push origin v1.2.0 # push one tag
git push --tags # push all tags (use with care)
git push origin --delete old-branch
The upstream (or tracking branch) of a local branch is the remote branch it normally exchanges with. Setting it is what lets a bare git push, git pull and git status know what to compare with. -u on the first push sets it; so does git switch when it creates a local branch from a remote one. You can also set it directly:
$ git branch -u origin/main
branch 'main' set up to track 'origin/main'.
$ git branch -vv
* main 8c15966 [origin/main] Add Git handbook (#1)
add-git-guide 7d14fd9 [origin/add-git-guide: gone] Write chapter 1"gone" means the remote branch was deleted (typically after its pull request merged) and your fetch pruned it. The local branch can then be deleted too.
When a push is rejected, Git explains why. If someone pushed while you were working:
To ../origin.git
! [rejected] main -> main (fetch first)
error: failed to push some refs to '../origin.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.The fix is to git pull (integrating their work), check the result, and push again. If you have already fetched their commits, the same situation is reported as (non-fast-forward).
5.4 Force Pushing Safely
After you rebase or amend commits that are already on the remote, an ordinary push is rejected, correctly, because the remote branch is no longer an ancestor of yours. Replacing the remote branch requires a force push. There are three levels of force:
| Command | What it checks |
|---|---|
git push --force | Nothing. Overwrites the remote branch with yours, discarding any commits there that you do not have. |
git push --force-with-lease | Only overwrites if the remote branch is still where your origin/<branch> says it was. If someone has pushed since you last fetched, it refuses with (stale info). |
git push --force-with-lease --force-if-includes (Git 2.30+) | Also checks that the remote tip you last fetched has actually been incorporated into your branch, not merely downloaded. |
The third form exists because of a trap that was confirmed in testing. --force-with-lease compares against your remote-tracking branch, and a plain git fetch (or an editor that fetches in the background) updates that without your having looked at the new commits. After such a fetch, --force-with-lease alone went ahead and overwrote a colleague's commit. With --force-if-includes added, the same push was refused:
! [rejected] topic -> topic (remote ref updated since checkout)
hint: Updates were rejected because the tip of the remote-tracking branch has
hint: been updated since the last checkout. If you want to integrate the
hint: remote changes, use 'git pull' before pushing again.A convenient alias: git config --global alias.pushf "push --force-with-lease --force-if-includes".
main. Rewriting it breaks every clone that has the old commits, and on GitHub it can destroy work others have merged. Force pushing is for your own feature branches. Protect main on GitHub so that force pushes are blocked (Section 8.4).5.5 Multiple Remotes, Forks and Upstream
To contribute to a project you cannot push to, you fork it on GitHub (making your own copy under your account), clone your fork, and propose changes by pull request from a branch on your fork. By convention your fork is origin and the original project is added as upstream:
git clone git@github.com:me/project.git
cd project
git remote add upstream https://github.com/them/project.git
git fetch upstreamKeep your fork's main identical to the project's and do all your own work on branches. To bring it up to date:
git switch main
git fetch upstream
git merge --ff-only upstream/main # or: git rebase upstream/main
git push origin mainGitHub can do the same from the web page ("Sync fork") or with gh repo sync. To refresh a feature branch with the project's latest work before opening or updating a pull request, rebase it onto upstream/main and force push it to your fork with lease.
Multiple remotes are useful beyond forks: for example a GitHub remote for collaboration and a second remote that a hosting service deploys from. git push --all origin pushes every branch to one remote; to push to two places at once, add a second push URL with git remote set-url --add --push origin <url> (you must then also add the original URL as a push URL, because the first --add --push replaces the default).
Git is extremely hard to lose committed work in, and surprisingly easy to lose uncommitted work in. The commands in this chapter differ mainly in two ways: which of the three areas they change, and whether they rewrite commits that may already be shared. Choose by answering those two questions, not by remembering a recipe.
6.1 Which Undo Do I Need?
| Situation | Command | Safe on pushed commits? |
|---|---|---|
| Discard edits to a file you have not staged | git restore file | Not applicable; edits are lost for good |
| Unstage a file but keep the edits | git restore --staged file | Not applicable |
| Fix the last commit's message or add a forgotten file | git commit --amend | No: rewrites the commit |
| Undo the last commit(s) but keep the changes staged | git reset --soft HEAD~1 | No |
| Undo the last commit(s) and keep the changes unstaged | git reset HEAD~1 | No |
| Throw away the last commit(s) and all uncommitted changes | git reset --hard HEAD~1 | No; also destroys uncommitted work |
| Undo a commit that is already pushed or shared | git revert <commit> | Yes: adds a new commit |
| Get back a commit lost to reset, rebase or branch deletion | git reflog, then git branch or git reset | Yes |
| Abandon a merge, rebase or cherry-pick in progress | git merge --abort (or rebase, cherry-pick) | Yes |
| Remove a file from Git but keep it on disk | git rm --cached file | Yes (history still contains it) |
Appendix 14.2 repeats this as a step-by-step decision chart. The rule underneath it: if anyone else may have the commits, add a new commit that undoes the change (revert); if the commits are yours alone, you may rewrite them (amend, reset, rebase).
6.2 Amend the Last Commit
git commit --amend -m "Add contact page with map" # new message
git add forgotten.css
git commit --amend --no-edit # same message, extra file
The amended commit has a new hash. If the original was already pushed, you will need a force push with lease (Section 5.4), which is only acceptable on a branch nobody else is using.
6.3 Reset and Its Three Modes
git reset <commit> moves the current branch to point at another commit. The mode decides how many of the three areas are then made to match it.
Tested on a branch of three commits, each changing f.txt:
$ git reset --soft HEAD~1 ; git status -s
M f.txt # change staged, ready to recommit
$ git reset HEAD~1 # --mixed is the default
Unstaged changes after reset:
M f.txt
$ git reset --hard HEAD~1
HEAD is now at b7fb5e8 Version 2Common uses: --soft to squash your last few unpushed commits into one (git reset --soft HEAD~3 then a single git commit); mixed to unpick a commit and re-stage it differently; --hard to throw away experiments, or to make a branch exactly match another (git reset --hard origin/main).
git reset --hard overwrites every uncommitted change in tracked files without asking. Committed work can be recovered through the reflog; uncommitted edits cannot. Run git status first, and stash anything you are not sure about.6.4 Revert: Undoing Shared History
$ git revert --no-edit HEAD
[main 105cbe9] Revert "Version 3"
1 file changed, 1 insertion(+), 1 deletion(-)
git revert a1b2c3d # undo an older commit
git revert -m 1 <merge> # undo a merge, keeping parent 1's side
git revert --no-commit A..B # stage reverts of several commits as one change
Reverting a merge commit needs -m 1 to say which parent is the "main line" (normally 1, the branch you merged into). Be aware that a reverted merge's commits count as already merged, so re-merging the same branch later will not bring them back; you would need to revert the revert.
On GitHub, a merged pull request has a "Revert" button that creates a new pull request containing the revert, which is the same thing done through review.
6.5 The Reflog: Recovering "Lost" Commits
Every time HEAD or a branch moves (commit, reset, rebase, switch, merge, amend), Git records the previous position in the reflog. It is local to your repository, never pushed, and is the safety net under almost every rewriting command.
$ git reflog -4
b7fb5e8 HEAD@{0}: reset: moving to HEAD~1
8ace6dd HEAD@{1}: commit: Version 3
b7fb5e8 HEAD@{2}: reset: moving to HEAD~1
8ace6dd HEAD@{3}: commit: Version 3
$ git reset --hard HEAD@{1} # undo the reset
HEAD is now at 8ace6dd Version 3The same works for a deleted branch. Here a branch with an unmerged commit was force-deleted and then recovered:
$ git branch -D spike
Deleted branch spike (was 29369fe).
$ git branch spike 29369fe # or find the hash with git reflogAnd for a rebase that went badly: find the entry just before "rebase (start)" in git reflog and git reset --hard to it. Each branch also has its own reflog (git reflog show feature), and feature@{1} means "where feature was one move ago".
How long is the safety net?
By default, git gc expires reflog entries after 90 days, and after 30 days for entries no longer reachable from the branch tip (settings gc.reflogExpire and gc.reflogExpireUnreachable). Unreferenced objects then become eligible for pruning after a further grace period, two weeks by default. In practice, if you lost something this month, you can almost certainly get it back. Uncommitted, unstashed edits were never recorded and are not covered.
6.6 What Is Safe on Shared History
| Always safe | Only on unpushed or private branches | Never on shared branches |
|---|---|---|
git revertNew commits that fix things git mergegit fetch, git log, git reflog | git commit --amendgit resetgit rebase, git rebase -iForce push with lease to your own branch | git push --force to mainRebasing main or a branch others build onDeleting a branch others are using |
The one deliberate exception is removing a leaked secret or a huge file from history, which does require rewriting shared history. Section 8.9 describes how to do that with the least damage, and why rotating the secret comes first.
History is only useful if you can question it. These commands answer "who changed this, and why?", "when did this break?", "where is this used?" and "what version is this?".
7.1 Blame: Who Last Changed Each Line
$ git blame index.html
b018ae2e (Ada Example 2026-10-04 19:31:51 +0000 1) <h1>Welcome, good morning</h1>
f7c948af (Ada Example 2026-10-04 19:31:38 +0000 2) <p>One!</p>
^f48c2f6 (Ada Example 2026-10-04 19:31:28 +0000 3) <p>Two</p>
git blame -L 10,20 index.html # only lines 10 to 20
git blame -w index.html # ignore whitespace-only changes
git blame -C -M index.html # follow lines moved or copied from elsewhere
git blame --ignore-rev 3f2a1bc index.html # skip a reformatting commit
A ^ before the hash marks a line unchanged since the root (first) commit. Then run git show <hash> on the interesting line's commit. GitHub's file view has a Blame button, and honours a .git-blame-ignore-revs file in the repository root listing commits (such as mass reformatting) to skip.
7.2 Bisect: Finding the Commit That Broke Something
When something worked at one point and is broken now, git bisect finds the first bad commit by binary search. You tell it one good commit and one bad one; it checks out the commit halfway between and asks you which it is, halving the range each time. A thousand commits take about ten steps.
Worked example
This example was run exactly as shown. A small site has a script, check.sh, that exits with 0 if every HTML page has a <title> and 1 if any does not:
#!/bin/sh
# Exit 0 if every page has a <title>, 1 if any page is missing one
for f in *.html; do
grep -q '<title>' "$f" || { echo "missing title: $f"; exit 1; }
doneTwelve commits each added a page. Release v1.0 (when page 2 was added) passed the check; today's tip fails it:
$ ./check.sh
missing title: page8.htmlOf course the message gives the game away here, but in real life the symptom rarely names its cause. Bisect manually first:
$ git bisect start
$ git bisect bad # the current commit is broken
$ git bisect good v1.0 # this one was fine
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[0f0e8882ea4a172ca5ead428a2586ac008593ba7] Add page 7
$ ./check.sh ; echo $?
0
$ git bisect good
Bisecting: 2 revisions left to test after this (roughly 1 step)
[84346d9a46c34225ebdbd46e1e6f047f47632643] Add page 9
$ ./check.sh ; echo $?
missing title: page8.html
1
$ git bisect bad
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[988b8eff09cbf33c4f00b85dd5169addd14c0572] Add page 8
$ git bisect bad
988b8eff09cbf33c4f00b85dd5169addd14c0572 is the first bad commit
$ git bisect reset # return to where you startedThree tests instead of up to ten. If a test command exists, Git can run the whole search itself. The script must exit with 0 for good and 1 to 127 for bad (125 means "cannot test this commit, skip it"):
$ git bisect start HEAD v1.0 # bad first, then good
$ git bisect run ./check.sh
running './check.sh'
...
988b8eff09cbf33c4f00b85dd5169addd14c0572 is the first bad commit
bisect found first bad commit
$ git bisect resetDuring a bisect you are in detached HEAD (Section 1.6), which is expected. git bisect skip skips a commit you cannot test (one that does not build, say), git bisect log shows your answers so far, and if you mark one wrongly, save the log, edit out the mistake and replay it with git bisect replay. If the test script is not in the repository at every commit you are testing, keep a copy outside the repository so it does not disappear as Git checks out older commits.
Why small commits pay off
Bisect points at a commit, not a line. If the guilty commit is "Add page 8", you are finished. If it is "Lots of changes", you still have work to do. One logical change per commit makes bisect, revert and cherry-pick all far more precise.
7.3 Grep: Searching Tracked Files
$ git grep -n Welcome
index.html:1:<h1>Welcome, good morning</h1>
$ git grep -n Hello HEAD~8 index.html
HEAD~8:index.html:1:<h1>Hello</h1>
git grep -i "todo" # case-insensitive
git grep -c "<table" # count matches per file
git grep -e "colour" --and -e "var(--" # lines matching both
To find when text appeared or vanished across history, use git log -S (Section 3.4) rather than grep.
7.4 Shortlog: Summarising Contributions
$ git shortlog -sne HEAD
21 Ada Example <ada@example.com>
git shortlog v1.0..HEAD # subjects since a release, grouped by author
Name a revision (such as HEAD) when using it in scripts: when standard input is not a terminal and no revision is given, shortlog reads a log from standard input instead, and in testing printed nothing at all.
7.5 Tags and Describe
A tag is a fixed name for a commit, normally used for releases. There are two kinds:
| Lightweight | Annotated | |
|---|---|---|
| Created with | git tag v0.1 | git tag -a v1.0 -m "First release" |
| Stored as | Just a name pointing at a commit (like a branch that never moves) | A tag object with tagger, date and message; can be signed |
git cat-file -t | commit | tag |
Seen by plain git describe | No (needs --tags) | Yes |
| Use for | Private bookmarks | Releases and anything you publish |
git push unless you say so.git tag -a v1.2.0 -m "Search across all handbooks" # annotated, at HEAD
git tag -a v1.1.1 9fceb02 -m "Hotfix" # at an older commit
git tag -n # list with messages
git tag -l "v1.*" # list matching
git push origin v1.2.0 # publish one tag
git tag -d v1.2.0 # delete locally
git push origin --delete v1.2.0 # delete on the remote
Treat a pushed tag as permanent. Moving a published tag to a different commit confuses everyone who already fetched it; make a new version number instead.
g plus the abbreviated hash. Useful for build numbers.$ git describe
v1.0
$ git commit -qm "Later work" ; git describe
v1.0-1-g932119b
$ git describe --tags --always # include lightweight tags; fall back to a hash
With no tag reachable it fails: "fatal: No tags can describe '447c669...'. Try --always, or create some tags."
Releases on GitHub
A GitHub release is built on a Git tag and adds a title, release notes and optional downloadable files. Create one from the repository's Releases page, or with GitHub CLI after pushing an annotated tag:
git tag -a v1.2.0 -m "Search across all handbooks"
git push origin v1.2.0
gh release create v1.2.0 --verify-tag --notes-from-tag
gh release create v1.2.0 --generate-notes # notes built from merged pull requestsIf the tag does not exist, gh release create creates one for you from the latest state of the default branch; --verify-tag prevents that surprise. Section 9.5 covers choosing version numbers.
Everything so far works with any Git host. This chapter covers what GitHub adds on top. GitHub's interface changes often, so menu names are given as they were at the time of writing; the feature descriptions and availability statements are taken from GitHub's own documentation.
8.1 Repositories, README and Licence
A GitHub repository is a hosted Git repository plus settings, issues, pull requests and so on. Its visibility is public (anyone can see it), private (only people you add), or, for organisations on some plans, internal. To put an existing local project on GitHub, create an empty repository on the website (no README, so the histories do not clash), then:
git remote add origin git@github.com:you/site.git
git push -u origin mainOr in one step with GitHub CLI: gh repo create site --private --source=. --push.
Three files make a repository usable by others:
- README.md: what the project is, how to run or build it, how to contribute. GitHub displays it on the repository's front page if it is in the root,
.githubordocsfolder. - A licence file, conventionally named
LICENSE: the terms on which others may use the work. GitHub's documentation is blunt that without one, default copyright law applies and nobody may reproduce, distribute or create derivative works from yours. For open source, pick a licence at choosealicense.com (run by GitHub) rather than writing your own. - .gitignore: Section 3.7.
Optional extras that GitHub recognises include CONTRIBUTING.md, SECURITY.md, a pull request template at .github/pull_request_template.md, and .github/CODEOWNERS, which automatically requests review from named people when files they own change.
8.2 Issues
Issues are GitHub's to-do list and discussion board: bug reports, feature requests, tasks. Each has a number, which you can reference anywhere on GitHub as #14. Labels, assignees and milestones organise them; Projects boards arrange them into columns or tables.
The most useful habit is linking pull requests to issues. A pull request whose description contains a closing keyword followed by an issue reference (close, closes, closed, fix, fixes, fixed, resolve, resolves or resolved, as in "Fixes #14") closes the issue automatically when merged. GitHub only acts on these keywords when the pull request targets the repository's default branch.
gh issue create --title "Drawer covers search on small phones" --label bug
gh issue list --assignee @me
gh issue view 148.3 Pull Requests and Reviews
A pull request (PR) proposes merging one branch into another, usually a feature branch into main. It shows the combined diff, runs automated checks, and gives reviewers a place to comment line by line. You keep pushing commits to the branch and the PR updates.
- Open a PR as a draft to share work in progress; drafts cannot be merged and do not automatically request code owners' reviews until marked ready.
- Reviewers submit one of three verdicts: Comment (general feedback), Approve, or Request changes. They can also attach suggested edits that the author applies with one click.
- Keep PRs small and focused. A 50-line PR gets a careful review; a 2,000-line one gets a skim.
When the PR is approved and checks pass, it is merged by one of three methods, which the repository owner can allow or disallow in settings:
| Method | Result on the base branch | Choose it when |
|---|---|---|
| Create a merge commit | All the branch's commits plus a merge commit (like git merge --no-ff) | You want the full development history and a visible join |
| Squash and merge | One new commit containing all the changes | Branch commits are messy; you want one commit per PR |
| Rebase and merge | Each commit replayed individually, no merge commit | Commits are already tidy and you want linear history |
GitHub notes that rebase and merge always creates new commit hashes and updates the committer, even where a local rebase would not. Squash merging has a practical side effect, confirmed in testing: your local branch's commits are not ancestors of the squashed commit, so git branch -d refuses afterwards with "not fully merged". Once you have checked the PR really was merged, delete the local branch with -D.
gh pr create --fill # title and body from your commits
gh pr create --draft --reviewer alice
gh pr list
gh pr checkout 42 # get someone's PR branch locally to test it
gh pr review 42 --approve
gh pr merge 42 --squash --delete-branch8.4 Branch Protection and Rulesets
Protection stops anyone, including you on a bad day, from pushing broken or unreviewed work straight to main. GitHub offers two mechanisms.
Branch protection rules (Settings, Branches) apply to branches matching a name pattern. Available options include: require a pull request before merging, with a number of approvals; require status checks (such as the workflow in Section 8.5) to pass; require conversation resolution; require signed commits; require linear history; block force pushes and deletion (both blocked by default on a protected branch); and restrict who can push.
Rulesets (Settings, Rules) are the newer system. According to GitHub's documentation, they differ in that several rulesets can apply to the same branch at once (the most restrictive version of each rule wins), they can be switched between Active and Disabled without being deleted, anyone with read access can see which rules apply, and they can cover tags as well as branches. (Rules on commit metadata, such as message patterns, are described in GitHub's Enterprise Cloud documentation.)
Availability
GitHub's documentation states that both protected branches and rulesets are available in public repositories on GitHub Free and GitHub Free for organisations, and in public and private repositories on GitHub Pro, Team and Enterprise Cloud. On a free personal account, therefore, private repositories cannot be protected. Check GitHub's plans page for the current position.
One detail catches people out: by default, branch protection rules do not apply to repository administrators, which on a personal repository means you. Tick "Do not allow bypassing the above settings" if you want the rules to bind you too. For a one-person project, a sensible minimum is: require a pull request (with zero required approvals if you are the only reviewer), require the check workflow to pass, and block force pushes. It costs a minute per change and prevents most self-inflicted accidents.
8.5 GitHub Actions Basics
GitHub Actions runs commands on GitHub's servers when something happens in the repository. The vocabulary:
- A workflow is a YAML file in
.github/workflows/. - It runs on one or more events: a push, a pull request, a schedule, a manual button press (
workflow_dispatch). - It contains jobs, each running on a fresh virtual machine called a runner (for example
ubuntu-latest). - Each job is a list of steps: either a shell command (
run:) or a reusable action (uses:), such asactions/checkout, which clones your repository onto the runner.
This workflow checks that every HTML page in a static site has a <title>, on every pull request and every push to main. It was validated with the actionlint linter and its script was run locally against a test repository:
# .github/workflows/check.yml
name: Check pages
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Every HTML page has a title
run: |
status=0
for f in $(git ls-files '*.html'); do
if ! grep -q '<title>' "$f"; then
echo "::error file=$f::No <title> element"
status=1
fi
done
exit $statusAny step that exits with a non-zero status fails the job, and the PR shows a red cross; make the check required in branch protection and the PR cannot be merged until it passes. The ::error file=...:: line is a workflow command that annotates the file in the PR view. permissions: contents: read limits the automatic GITHUB_TOKEN to reading, a good default.
Version note
Actions are referenced by major version tag (@v4). New major versions appear from time to time; at the time of writing GitHub's own quickstart used a newer actions/checkout than the starter workflows did. Both work. Let Dependabot (Section 8.8) propose upgrades rather than chasing them by hand. GitHub states that Actions is free for public repositories; private repositories get a monthly allowance of minutes that depends on the plan.
Useful CLI commands: gh run list to see recent runs, gh run watch to follow one live, and gh run view --log-failed to read why one failed.
8.6 GitHub Pages
GitHub Pages serves a static website (HTML, CSS, JavaScript, images) straight from a repository, at https://<owner>.github.io/<repository>/, or at https://<owner>.github.io/ for a repository named <owner>.github.io. Custom domains are supported. It is configured under Settings, Pages, with two kinds of source:
- Deploy from a branch: choose a branch and either its root folder or a
/docsfolder. Pages runs the files through the Jekyll static site generator unless the folder contains a file called.nojekyll; for plain hand-written HTML, add.nojekyllso files and folders beginning with an underscore are not ignored. - GitHub Actions: a workflow builds the site and deploys it. GitHub's "Static HTML" starter workflow uploads the repository as-is:
# .github/workflows/pages.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: '.'
- id: deployment
uses: actions/deploy-pages@v4Limits published by GitHub include a 1 GB maximum published site, a soft bandwidth limit of 100 GB a month, and a 10-minute deployment timeout. Pages is available for public repositories on GitHub Free and for private ones on paid plans, and GitHub warns that a Pages site is publicly available on the internet even when its repository is private (except for privately published sites on Enterprise Cloud). It is not permitted as hosting for an online business or e-commerce site.
8.7 GitHub CLI Basics
gh handles the GitHub-only parts (pull requests, issues, releases, workflow runs). Tested commands below come from gh 2.89's built-in help.gh auth login # sign in (browser flow by default)
gh auth status
gh repo clone owner/repo
gh repo fork owner/repo --clone # fork and clone, with upstream set up
gh repo view --web # open the repository in the browser
gh pr create --fill
gh pr status # PRs relevant to you
gh pr checks # status checks on the current branch's PR
gh issue list
gh release create v1.2.0 --generate-notes
gh run list
gh browse # open the current repository on GitHub
Install: winget install --id GitHub.cli --source winget on Windows, brew install gh on macOS, and GitHub's own apt repository on Ubuntu (see cli.github.com for the current instructions).
8.8 Dependabot
Dependabot watches the dependencies your project declares and opens pull requests to update them. Security updates respond to published vulnerabilities; version updates keep dependencies current on a schedule and are configured by a file at .github/dependabot.yml. Even a plain static site has dependencies if it uses GitHub Actions, because each uses: line is one:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"Other ecosystems include npm, pip and docker; add one block per ecosystem. Dependabot's PRs run your checks like any other, so a green tick means the update did not break anything your workflow tests.
8.9 Secrets: Prevention and Cure
Passwords, API keys, tokens and private keys must never be committed. Once a secret is pushed, assume it has been copied: public repositories are scanned by automated harvesters, and every clone and fork carries the full history.
Prevention
- Keep secrets in files that are ignored from the first commit (
.env,*.pem,credentials.json), and commit an example file with dummy values (.env.example) instead. - In GitHub Actions, store secrets under Settings, Secrets and variables, Actions, and reference them as
${{ secrets.NAME }}. Actions masks registered secrets in logs. Except forGITHUB_TOKEN, secrets are not passed to workflows triggered by pull requests from forks. - GitHub's secret scanning runs automatically and for free on public repositories, and notifies partner providers so they can revoke leaked credentials. Push protection blocks pushes that contain recognised secrets; GitHub's documentation states that push protection for users is enabled by default for pushes to public repositories.
- A
pre-commithook (Section 10.4) can catch obvious patterns before they leave your machine.
If you have committed a secret
- Revoke or rotate the secret first, immediately. Generate a new key or password with the provider and disable the old one. This is the step that actually protects you, and GitHub's guidance is that it may be all you need: a revoked credential in history is harmless.
- Check for misuse using the provider's access logs.
- If it was never pushed, fix the commit locally:
git rm --cachedthe file, add it to.gitignore, andgit commit --amend(or interactive rebase if it is further back). - If it was pushed and must be purged (for example, it cannot be rotated, or policy requires removal), rewrite history with
git filter-repo, the tool GitHub recommends. GitHub's procedure, run in a fresh clone, uses filter-repo 2.47 or later:git filter-repo --sensitive-data-removal --invert-paths --path config/secrets.yml # or, to replace strings listed in a file wherever they occur: git filter-repo --sensitive-data-removal --replace-text ../passwords.txt git push --force --mirror origin - Clean up the aftermath. Collaborators must rebase (not merge) any work onto the rewritten history, or a single
git pullandgit pushwill reintroduce the secret. Forks keep their copy unless their owners act. GitHub Support can remove cached views and dereference affected pull requests; their documentation lists what to send.
Git does not tell a team how to use branches. A workflow is the agreement about that: which branches exist, how long they live, and how work reaches production. Three are widely used, and two conventions for messages and version numbers sit alongside them.
9.1 Trunk-Based Development
The reference site trunkbaseddevelopment.com defines it as a branching model in which developers collaborate on code in a single branch called "trunk" (today usually main) and resist pressure to create other long-lived branches. Short-lived feature branches for review and automated checks are allowed, but they last hours or a day or two, not weeks.
- Strengths: integration happens constantly, so merge conflicts stay small;
mainalways reflects reality. - Demands: good automated tests, and feature flags (configuration switches) so that unfinished features can be merged while hidden from users.
- Suits: teams practising continuous delivery, and solo work.
9.2 GitHub Flow
GitHub's own lightweight workflow, documented on docs.github.com, has six steps:
- Create a branch from
mainwith a short, descriptive name. - Make changes and commit them, pushing regularly.
- Open a pull request to ask for feedback.
- Address review comments with further commits.
- Merge the pull request once approved;
mainis then deployable. - Delete the branch.
It is essentially trunk-based development with pull requests as the gate, and it is the natural fit for websites and other continuously deployed projects. Chapter 11 walks through it end to end.
9.3 Git Flow
Described by Vincent Driessen in "A successful Git branching model" (January 2010), Git flow uses two permanent branches and three kinds of temporary one:
| Branch | Purpose |
|---|---|
master (or main) | Production releases only; every merge into it is a new, tagged release |
develop | Integration branch for the next release |
feature/* | Branch from and merge back into develop |
release/* | Branch from develop to stabilise a release; merge into both master and develop |
hotfix/* | Branch from master for urgent fixes; merge into both |
In a note added in March 2020, the author himself advised that teams doing continuous delivery of software, such as web apps, should adopt a much simpler workflow like GitHub flow, and that Git flow remains a fit for explicitly versioned software where several versions must be supported in the wild. If you are not shipping numbered versions to customers, you probably do not need it.
9.4 Conventional Commits
Conventional Commits (specification 1.0.0, conventionalcommits.org) is a structured commit message format that tools can read to generate changelogs and decide version numbers:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]feat(search): search across all handbooks
fix: stop drawer covering the search box on small phones
docs: explain line endings in the Git handbook
feat!: drop support for the old URL scheme
BREAKING CHANGE: links of the form /guides/x.html no longer redirect.fixcorresponds to a PATCH release andfeatto a MINOR release in Semantic Versioning.- A
!after the type or scope, or aBREAKING CHANGE:footer, marks a breaking change, corresponding to a MAJOR release. - Other types are allowed; the specification mentions
build,chore,ci,docs,style,refactor,perfandtest, from the Angular convention.
It is a convention, not a Git feature. Adopt it if you will use the tooling or if your project requires it; otherwise the plain guidance in Section 3.3 serves just as well.
9.5 Semantic Versioning
Semantic Versioning 2.0.0 (semver.org) gives version numbers the form MAJOR.MINOR.PATCH and says to increment:
- MAJOR for incompatible API changes,
- MINOR for functionality added in a backward compatible way,
- PATCH for backward compatible bug fixes.
Version 0.y.z is for initial development, when anything may change. Pre-release and build labels can follow, as in 1.4.0-rc.1. Tag releases with the version, conventionally prefixed with v (git tag -a v1.4.0); note that the specification itself says the "v" is not part of the version number. A website rarely has an "API" in this sense, so many sites simply tag dated releases or do not tag at all; versioning matters most for things other people install or depend on.
9.6 Choosing a Workflow
| Situation | Suggested workflow |
|---|---|
| Solo project or personal website | GitHub flow with short branches; protect main and let a check workflow guard it |
| Small team deploying continuously | GitHub flow or trunk-based with feature flags |
| Software released as numbered versions, several supported at once | Git flow, or trunk-based with release branches cut from main |
| Contributing to someone else's open source project | Whatever their CONTRIBUTING file says; usually fork plus GitHub flow |
These tools solve specific problems. Each gets a short introduction: enough to recognise when you need it and to use it safely, with the official documentation as the next step.
10.1 Submodules
A submodule embeds one repository inside another at a fixed commit. The outer repository records only the URL (in a .gitmodules file) and the exact commit of the inner one, not its files.
git submodule add https://github.com/owner/lib.git vendor/lib
git clone --recurse-submodules git@github.com:me/site.git # clone with submodules
git submodule update --init --recursive # after a plain clone, fetch them
git submodule update --remote vendor/lib # move to the submodule's latest commitAfter update --remote the outer repository shows the submodule as modified; commit that to record the new pinned version. Submodules check out in detached HEAD, which surprises people who then commit inside them; switch to a branch first if you intend to work on the inner project.
Version note
Since the security release Git 2.38.1 (and the matching maintenance releases), protocol.file.allow defaults to "user", so adding a submodule from a local path fails with fatal: transport 'file' not allowed. This was reproduced in testing. It does not affect submodules from GitHub URLs; for a trusted local path you can override it for one command with git -c protocol.file.allow=always submodule add ....
Submodules are powerful but awkward for collaborators, who must remember the extra commands. For a few shared files, copying them in (or a package manager) is often simpler.
10.2 Worktrees
A worktree is an extra working folder attached to the same repository, with a different branch checked out. It is the answer to "I need to fix something on main but I am in the middle of a feature and do not want to stash".
$ git worktree add ../site-hotfix -b hotfix
Preparing worktree (new branch 'hotfix')
$ git worktree list
/home/ada/site 932119b [topic]
/home/ada/site-hotfix 932119b [hotfix]
$ git worktree remove ../site-hotfixAll worktrees share one .git store, so commits made in one are immediately visible in the others. A branch can be checked out in only one worktree at a time; trying to switch to it elsewhere fails with "fatal: 'hotfix' is already used by worktree at ...".
10.3 Git LFS for Large Files
Git keeps every version of every file forever, so large binaries such as video, design files and datasets make a repository slow to clone. GitHub warns on files over 50 MiB and blocks files over 100 MiB; it recommends keeping repositories ideally under 1 GB, and strongly recommends under 5 GB. Git Large File Storage replaces such files in the repository with small text pointers and stores the real content on the LFS server.
git lfs install # once per user account
git lfs track "*.psd" # writes a rule to .gitattributes
git add .gitattributes art.psd
git commit -m "Add artwork"
git lfs ls-files # which files are in LFSIn testing (Git LFS 3.4.1), the committed "file" was in fact this pointer:
$ git show HEAD:art.psd
version https://git-lfs.github.com/spec/v1
oid sha256:79f2689a9a2d50b2694819ac54761141663597554a3788a448b251114c2ac63e
size 2000Set up tracking before committing the large files; files already in history stay there until migrated (git lfs migrate, which rewrites history). LFS storage and bandwidth on GitHub are subject to plan quotas, listed in GitHub's billing documentation. Images for a website are usually better optimised (WebP, sensible dimensions) than moved to LFS.
10.4 Hooks
Hooks are scripts Git runs at certain points: pre-commit before a commit is created, commit-msg to check the message, pre-push before a push, and others. Samples live in .git/hooks/; remove the .sample suffix and make a script executable to activate it. A hook that exits non-zero stops the operation.
This tested pre-commit hook refuses commits whose staged changes contain the text PRIVATE_KEY:
#!/bin/sh
# .git/hooks/pre-commit
if git diff --cached | grep -q 'PRIVATE_KEY'; then
echo "pre-commit: staged changes contain PRIVATE_KEY; commit refused." >&2
exit 1
fi$ git commit -m "oops"
pre-commit: staged changes contain PRIVATE_KEY; commit refused.Two limits to understand. Hooks in .git/hooks are not part of the repository, so they are not cloned or shared; to share them, commit them to a folder such as .githooks/ and have each person run git config core.hooksPath .githooks. And git commit --no-verify skips pre-commit and commit-msg hooks, so they are a convenience for honest mistakes, not enforcement. Enforcement belongs in a required GitHub Actions check (Section 8.5), which nobody can skip.
10.5 Signing Commits
Anyone can set user.name and user.email to anything, so a commit's author line proves nothing. A signed commit carries a cryptographic signature, and GitHub marks it "Verified" when the signature matches a signing key on the author's account. Branch protection can require signed commits.
Git can sign with GPG, S/MIME or, since Git 2.34, an SSH key. GitHub describes SSH signatures as the simplest, and you can upload the same key you use for authentication a second time as a signing key:
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true # sign every commit
git config --global tag.gpgsign true # and every annotated tag
git commit -S -m "Signed commit" # or sign just oneThen in GitHub, Settings, SSH and GPG keys, add the public key again with key type "Signing Key". To verify SSH signatures locally (git log --show-signature), Git also needs an "allowed signers" file listing trusted keys, set with gpg.ssh.allowedSignersFile.
Testing note
The test machine had no OpenSSH tools installed, so the SSH steps in Sections 2.5 and 10.5 follow GitHub's and Git's documentation rather than a local run. The Git 2.34 requirement and the release note's warning that SSH signing does not work with ssh-keygen from OpenSSH 8.7 (use 8.8 or later) come from the Git 2.34.0 release notes.
This chapter follows one change to a small static website from idea to live site, using GitHub flow. The site is like this hub: hand-written HTML pages, a shared stylesheet, no build step, and a host that publishes whatever is on main. The Git side was run end to end in test repositories, with a second clone standing in for GitHub's merge button and for a collaborator, so the output shown is real; the GitHub web steps are described.
11.1 The Starting Point
site/
├── index.html
├── assets/
│ └── hub.css
├── guides/
│ └── plumbing/index.html
├── .github/
│ ├── workflows/check.yml # the title check from Section 8.5
│ └── dependabot.yml
├── .gitattributes # * text=auto
├── .gitignore
└── README.mdRepository settings on GitHub: main is protected (pull request required, the "Check pages" workflow must pass, force pushes blocked, no bypassing), and only squash merging is allowed, so each pull request becomes one commit on main. Locally, pull.rebase true is set.
11.2 Start a Branch for the Work
- Start from an up-to-date main.
git switch main git pull - Create a branch named after the task.
$ git switch -c add-git-guide Switched to a new branch 'add-git-guide' - Make the first piece of work and commit it. Keep commits small and meaningful.
mkdir -p guides/git $EDITOR guides/git/index.html git add guides/git/index.html git commit -m "Add Git handbook skeleton" - Push the branch and set its upstream.
$ git push -u origin add-git-guide * [new branch] add-git-guide -> add-git-guide branch 'add-git-guide' set up to track 'origin/add-git-guide'. - Open a draft pull request so the checks start running and the work is visible:
Write in the description what the page is and anything a reviewer should look at, and add "Closes #23" if there is an issue for it.
gh pr create --draft --fill
11.3 Keep Working, and Keep Up with Main
More commits follow; each git push updates the pull request and reruns the check. Meanwhile, someone else merges a change to the home page into main. Your branch is now behind. It is not urgent, but integrating early keeps conflicts small, and the protected branch may require the PR to be up to date before merging.
$ git fetch
From github.com:you/site
15d9c34..22ec102 main -> origin/main
$ git rebase origin/main
Successfully rebased and updated refs/heads/add-git-guide.Rebase was safe here because the branch is yours alone, despite being pushed: nobody else has built on it (Section 4.4). It did rewrite the two commits, so a plain push is now rejected:
$ git push
! [rejected] add-git-guide -> add-git-guide (non-fast-forward)
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. ...Do not follow the hint to pull here; that would try to combine the old copies of your commits with the new ones. This is the case for a leased force push:
$ git push --force-with-lease --force-if-includes
+ 2c2233c...7d14fd9 add-git-guide -> add-git-guide (forced update)If you prefer never to force push, git merge origin/main into the branch instead also works; with squash merging, the extra merge commit disappears from main anyway. GitHub's "Update branch" button on the PR does the same merge for you.
11.4 Review and Merge
- Mark the PR ready (
gh pr ready). The reviewer reads the diff, perhaps checks out the branch to view the page locally (gh pr checkout 1then open the file in a browser), and leaves comments. - Address each comment with a new commit and push. Reply to or resolve the conversations.
- When approved and the check is green, merge with "Squash and merge", editing the squashed commit message into a proper summary. Delete the branch on GitHub when offered.
If you are working alone, review your own PR anyway: reading the full diff on a different screen catches a surprising number of mistakes, and the required check catches some of the others.
11.5 Tidy Up Locally
$ git switch main
$ git pull
2 files changed, 3 insertions(+)
create mode 100644 guides/git/index.html
$ git fetch --prune
- [deleted] (none) -> origin/add-git-guide
$ git branch -vv
add-git-guide 7d14fd9 [origin/add-git-guide: gone] Write chapter 1
* main 8c15966 [origin/main] Add Git handbook (#1)
$ git branch -d add-git-guide
error: the branch 'add-git-guide' is not fully merged.
If you are sure you want to delete it, run 'git branch -D add-git-guide'That error is expected after a squash merge: the squashed commit on main contains your changes but not your commits. Having confirmed the PR was merged, use git branch -D add-git-guide. The history of main is now one tidy commit per change:
$ git log --oneline --graph
* 8c15966 Add Git handbook (#1)
* 22ec102 Link guides from home
* 15d9c34 Initial site11.6 When Something Goes Wrong After Release
Suppose the new page breaks the navigation on phones and the fix is not obvious. The site is live and main is shared, so do not reset it. Revert the merge through GitHub's "Revert" button on the merged PR (which opens a new PR) or locally:
git switch -c revert-git-guide
git revert 8c15966 # a squash commit has one parent, so no -m needed
git push -u origin revert-git-guide
gh pr create --fillMerge that, and the site is back to its previous state within one deploy. Then fix the problem on a fresh branch at leisure. Optionally, mark known-good states with annotated tags (git tag -a 2026-10-04 -m "Git handbook published") so there is always a named point to compare with or bisect from.
11.7 The Routine in Brief
| Step | Command |
|---|---|
| Update main | git switch main && git pull |
| Start work | git switch -c short-descriptive-name |
| Commit in small steps | git add -p, git commit |
| Publish and open PR | git push -u origin HEAD, gh pr create --draft --fill |
| Catch up with main | git fetch, git rebase origin/main, git push --force-with-lease --force-if-includes |
| Merge | On GitHub after review and green checks |
| Tidy | git switch main, git pull, git fetch --prune, git branch -D name |
Git's error messages are long but usually accurate: read the whole message, including the hint: lines, before searching the web. The messages below are quoted as Git 2.43 prints them (GitHub's server messages as GitHub documents them), grouped roughly by when you meet them. Run git status first in almost every case; it tells you what state you are in and how to get out.
12.1 Pushing and Pulling
12.2 Authentication and Access
12.3 Branches, Merges and State
12.4 Files and Line Endings
12.5 "I Have Made a Mess"
Stop, and do not run commands at random. Then:
git status: is an operation in progress? If so,--abortit to get back to where you started.git stash -uto put any uncommitted work somewhere safe.git reflogto find the last state you were happy with.git branch rescue <hash>to label it before you do anything else, then compare (git log --oneline --graph --all) and either reset your branch to it or cherry-pick from it.- If you are still unsure, copy the whole project folder,
.gitincluded, before experimenting. A copy of the folder is a complete backup of the repository.
uses:, such as actions/checkout.git commit --amend). Creates a new commit with a new hash..git. Servers hold bare repositories; git init --bare makes one.main for new GitHub repositories.--force-with-lease and only on your own branches.git add -p works with.#N.origin/main, updated by fetch and push.git stash and restored with git stash pop..github/workflows.git worktree add.14.1 Appendix A: Command Cheat Sheet
| Task | Command |
|---|---|
| Set up | |
| Identity | git config --global user.name "Name"; user.email |
| Default branch name | git config --global init.defaultBranch main |
| New repository / copy one | git init / git clone URL |
| Look | |
| What state am I in? | git status (-sb for short) |
| Unstaged / staged changes | git diff / git diff --staged |
| History as a graph | git log --oneline --graph --decorate --all |
| When did this text appear? | git log -S"text" |
| One commit / one old file | git show HASH / git show HASH:path |
| Who changed this line? | git blame -L 10,20 file |
| Record | |
| Stage all / stage selectively | git add . / git add -p |
| Commit | git commit -m "Summary" |
| Fix last commit | git commit --amend |
| Stop tracking, keep file | git rm --cached file |
| Branch | |
| List / create and switch | git branch -vv / git switch -c name |
| Switch / go back | git switch name / git switch - |
| Merge into current | git merge name |
| Replay onto main | git rebase main |
| Tidy own commits | git rebase -i main |
| Copy one commit here | git cherry-pick HASH |
| Set work aside | git stash -u, later git stash pop |
| Delete merged / unmerged branch | git branch -d name / -D name |
| Share | |
| Download, no changes to your branches | git fetch --prune |
| Download and integrate | git pull (--rebase) |
| First push of a branch / later pushes | git push -u origin name / git push |
| Push after rewriting own branch | git push --force-with-lease --force-if-includes |
| Delete remote branch | git push origin --delete name |
| Release tag | git tag -a v1.0.0 -m "...", git push origin v1.0.0 |
| GitHub CLI | |
| Sign in | gh auth login |
| Open / check out / merge a PR | gh pr create --fill / gh pr checkout N / gh pr merge N --squash |
| CI runs | gh run list, gh run view --log-failed |
14.2 Appendix B: Undo Decision Chart
Work down the questions and stop at the first "yes".
| # | Question | If yes |
|---|---|---|
| 1 | Is a merge, rebase, cherry-pick or revert in progress (git status says so)? | git <operation> --abort to return to the start |
| 2 | Are the changes you regret uncommitted and unstaged? | git restore file (permanent; stash first if unsure) |
| 3 | Are they staged but not committed? | git restore --staged file to unstage, keeping the edits |
| 4 | Is it only the last commit's message or a forgotten file, and not pushed? | git commit --amend |
| 5 | Are the unwanted commits pushed to a branch others use? | git revert HASH (with -m 1 for a merge) and push |
| 6 | Unpushed commits, and you want to keep the changes? | git reset --soft HEAD~N (staged) or git reset HEAD~N (unstaged) |
| 7 | Unpushed commits, and you want them and all uncommitted work gone? | git reset --hard HEAD~N (check git status first) |
| 8 | Did a reset, rebase, amend or branch deletion lose commits? | git reflog, then git branch rescue HASH or git reset --hard HASH |
| 9 | Did you commit a secret? | Rotate it now, then Section 8.9 |
| 10 | Did you commit a file that should be ignored? | Add to .gitignore, git rm --cached file, commit |
14.3 Appendix C: .gitignore Templates
Starting points only: trim them to what your project actually produces. GitHub publishes a larger collection of templates in its github/gitignore repository, and offers them when you create a repository. Remember that ignoring does not untrack files that are already committed.
Static website (hand-written HTML, like this hub)
# Secrets and local configuration
.env
.env.*
!.env.example
# Generated output, if you ever add a build step
_site/
dist/
build/
# Logs and temporary files
*.log
*.tmp
*.swp
# Large originals kept outside the published site
/originals/Node.js project
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dist/
build/
coverage/
.env
.env.*
!.env.examplePython project
__pycache__/
*.py[cod]
.venv/
venv/
build/
dist/
*.egg-info/
.pytest_cache/
.coverage
.envGlobal ignore file for your own machine (~/.config/git/ignore)
# macOS
.DS_Store
# Windows
Thumbs.db
Desktop.ini
# Editors
.vscode/
.idea/
*~
*.swpEditor folders
Some teams deliberately commit shared editor settings (for example .vscode/extensions.json). If your project does, either leave that folder out of your global ignore file or have the project's .gitignore re-include it. In testing, a lone !.vscode/extensions.json was not enough against a global .vscode/ rule, because the folder itself was excluded; these three lines in the project's .gitignore worked:
!.vscode/
.vscode/*
!.vscode/extensions.json