Yet Another Git Guide

Posted on Sep 24, 2021

Yet Another Git Guide

Introduction

I use Git (and its companions like GitHub) a lot, and intend to use it a lot more. As I continue to learn, I’ll add to this document, written in the form of a tutorial to myself. It’s something I’d like to keep public so that I can point friends and collaborators to it.

I don’t aim or claim to be comprehensive, so I’ll only cover what’s relevant to how I personally use Git. I find the following resources to be useful:

  • Git manual pages. These are nice if you know what you’re looking for, but hard to browse because the manual is split up into pages for each subcommand. To see these, run man git-<subcommand>, or find the page online.
  • Atlassian’s Advanced Tips for Git. These are lengthier compared to what I have here, with better illustrations, and distributed over several articles.
  • Scott Chacon and Ben Straub’s Pro Git, especially the Git Internals chapter. Goes into a lot detail about the inner workings of Git.
  • Katie Sylor-Miller’s Oh Shit, Git!?!. Quick and dirty (language) guide for common Git maladies.
  • Roger Dudler’s git - the simple guide. Nice place to get started, with nice illustrations.

If you come across this and have any suggestions or corrections, please reach out.

Useful commands

Here is a summary of useful commands that you may use while using Git.

Check state of local repo:

git status

Commits

To add stage a file (include it in the next commit):

git add <file>

To restore from index (also known as staging area):

git restore <file>

To unstage a file (restore from HEAD):

git restore --staged <file>

To move a file (rename it in the next commit):

git mv <src> <dst>
mv <src> <dst> && git add <src> <dst> # equivalent

To delete a file (remove it in the next commit):

git rm <file>
rm <file> && git add <file> # equivalent

To make a commit:

git commit                      # opens up $EDITOR to compose commit message
git commit -v                   # short for --verbose; show diff in $EDITOR
git commit -m <commit-message>  # short for --message; inline commit message

To amend last commit with state of current index:

git commit --amend              # opens up $EDITOR to amend commit message

Note that --amend undoes the last commit. If that commit has already been pushed elsewhere, this will create a divergence in commit history, which will require merges, rebases, or force pushes to fix.

To abort an in-progress commit in the interactive editor, just delete leave the commit message blank.

To undo the last commit:

git reset --soft HEAD^ # shorthand for "most recent commit"

Commit history

To see the commit history:

git log
git log -- <path> # only show commits related to <path>
git log --graph   # show commit history as dag

To inspect working tree at a commit:

git checkout <hash>

To restore HEAD to a commit:

git reset <hash>         # same as --mixed
git reset --soft  <hash> # do not touch working tree or index
git reset --mixed <hash> # restore index but not working tree; default
git reset --hard  <hash> # restore working tree and index

Branches

To see all local branches (* next to current branch):

git branch
git branch -a # short for --all; also shows remote branches
git branch -vv # short for --verbose; also shows HEAD commit and upstream

To check out an existing branch:

git switch <branch-name>
git checkout <branch-name> # also works, overloaded legacy command name

Create and switch to a new branch:

git switch -c <branch-name>
git checkout -b <branch-name> # also works, overloaded legacy command name

Delete a branch:

git branch -d <branch-name> # delete branch only if fully merged into upstream
git branch -D <branch-name> # delete branch forcibly

Set upstream:

git branch -u <upstream> # short for --set-upstream-to=<upstream>

Remotes

List remotes:

git remote      # show remote names only
git remote -v   # show remote names and URL

Create remote:

git remote add <remote-name> <remote-url>

Rename remote:

git remote rename <old> <new>

Set remote URL:

git remote set-url <remote-name> <remote-url>

Collaboration

To push to current branch’s upstream:

git push    # fails if unable to fast-forward upstream
git push -f # short for --force; forcibly updates upstream to mirror local

To push to any remote, any branch (note the lack of / between <remote> and <branch>):

git push <remote> <branch>

To download commits/refs from remote (but not do anything to any local branches):

git fetch <remote>
git fetch <remote> <branch>

To merge commits into current branch:

git merge <from-local-branch>
git merge <from-remote>/<branch>  # should git fetch first

Fetch and merge (“pull”) commits:

git pull                                          # from upstream remote branch
git pull <remote> <branch>                        # from specified remote branch
git fetch <remote> && git merge <remote>/<branch> # equivalent to above

Note that the above commands may create merge commits if the history between local and remote branch have diverged. The below commands avoid this:

git pull --ff-only            # fail if diverged
git pull --rebase             # rebase automatically if diverged
git pull --rebase=interactive # rebase interactively if diverged

Rebasing commit history:

git rebase                        # rebase current branch onto upstream branch
git rebase --onto <other-branch>  # rebase current branch with <other-branch> as new base

Note that rebasing does not accommodate merges (without special flags).

To amend commit history during rebase (edit, reorder, squash, etc.):

git rebase -i # short for --interactive; specify what to do for each commit

Note that any kind of rebase (via git rebase or via git pull --rebase) may rewrite history. Like git commit --amend, if your history prior to rebase had already been published to a remote, you will need to merge, rebase, and/or force push in order to align the diverged histories.

Concepts

Commits and commit histories

Commits point to a snapshot of what a repo looks at a certain point in time, and each commit has zero or more parent commits. Commits with zero parents are root commits, while commits with two or more commits are called merge commits.

The commit history of a repo forms a directed, acyclic graph of snapshots of the repo. To visualize this DAG, run:

git log --graph

Note that all commits in this graph are reachable by traversing the parent commits from the latest commit. When all commits only have a single parent, we say that the commit history is linear. This is desirable because it totally orders all the commits, making it as if all modifications to the repo were performed sequentially.

Branches

It is often useful to work with several commit histories concurrently. In Git, you do this with branches. A branch is just a convenient way of referring to a particular commit, called the HEAD commit. Whenever you make a commit, you update the HEAD commit to point to the new latest commit.

You can see all the branches available in your repo with the command:

git branch

There will be a * next to the branch you are currently on. We say this branch is “checked out”. By default, you should be checked out on the main branch (previously, this was named master, but that is being phased out).

To check out another existing branch, run:

git checkout <branch-name>

This will restore your working directory to the snapshot captured by the HEAD commit of the branch you are switching to. Note that this may fail if you have uncommitted changes on your current branch that would be overwritten by checking out the other branch, so you should always make sure to do that before checking out a new branch.

Merging vs rebasing

When different commits are made to different branches, their commit histories are said to have diverged. Even if they don’t conflict (e.g., because they only make changes to different files), it still isn’t obvious how best to combine these histories.

For instance, consider two branches, main and topic, with a shared history starting at commit B. topic builds commits E, F, and G off of B, but main has since moved on and added commits C and D. This leads to the following graph:

      E---F---G   topic
     /
A---B---C---D     main

Note that from the perspective of topic, C and D just don’t exist; if you run git log on topic, you will only see the following:

A---B---E---F---G   topic

The easiest resolution here is to create a merge commit M to join together the two histories:

      E---F---G---M   topic
     /           /
A---B------C----D     main

If there are any conflicts between E-F-G and C-D, this merge commit is also an opportunity to mark how these conflicts are resolved (recall that it is just a snapshot of the working tree state).

Now, assume that after committing H onto topic, we are finally finished working on it, and want to merge it back into main. If main has not accummulated any subsequent commits since D, then all we need to do is just point its HEAD to that of topic, commit H:

      E---F---G---M---H   main
     /           /
A---B------C----D

Note that this leaves behind a non-linear history that can quickly accumulate into a sprawl as main accummulates merge commits from other branches.

An alternative to merging is to rebase topic’s commits onto main. Consider the commit graph prior to the merge:

      E---F---G   topic
     /
A---B---C---D     main

Instead of reusing commits E, F, and G, we can replay those changes on top of D, and create alternative commits E', F', and G':

              E'--F'--G'  topic
             /
A---B---C---D             main

Note that E', F' and G' are distinct commits from E, F, and G, because the contents of their working tree are now based on changes introduced by C and D. E, F, and G still exist in the sense that they weren’t actually rewritten here, but are no longer accessible from the topic branch (whose HEAD now points to G'):

        E---F---G         (abandoned)
       /
      /       E'--F'--G'  topic
     /       /
A---B---C---D             main

Yet we still say that we are rewriting history because from the perspective of topic, it does appear that we’ve introduced entirely new commits to replace the old ones. And while we’re rewriting history anyway, we can introduce additional changes as we’re creating these new commits.

We typically access these additional features by performing an interactive rebase:

git rebase -i # short for --interactive

This opens up your editor with the list of the commits the rebase is going to perform, and allows you to edit that list to instruct Git to do something different:

pick E Commit message of E
pick F Commit message of F
pick G Commit message of G

For instance, you can change the commit message at any point, or pause the rebase to edit some files before resuming, etc. For instance, to perform E last, after F and G:

pick F Commit message of F
pick G Commit message of G
pick E Commit message of E

Another thing you can do is combine commits, otherwise known as squashing commits. This accumulates their changes into a single commit. For instance, to squash commits F and G together, before performing E:

pick F Commit message of F
squash G Commit message of G
pick E Commit message of E

If you have a messy or outdated commit history, you can use interactive rebasing to tidy it up locally and bring it up to date, before pushing or merging it elsewhere.

However, be mindful that this does rewrite history, and so if your pre-rebase commits were already published elsewhere, they will likely result in conflicts. For instance, if you had already pushed E, F, and G upstream, you will be unable to push your rebased branch without first pulling to merge those commits back in, defeating the purpose of rebasing in the first place. In this case, the solution is to force push:

git push -f # short for --force

This will forcibly overwrite the upstream HEAD to your current HEAD. If those commits upstream contain any data not in your local commit history, they will be lost, so force pushing is usually done as a last resort.