In lesson 02-06 we learned to move around the history: git log with its formats, its filters by author and date, and searching with -S and -G. That was enough for a single-branch project with a handful of commits.

task-manager is now eight months old, with four active branches, merges, version tags and three people working on it. And the questions the team asks itself have changed in nature:

  • What shape does the history have? What has been merged into main this month?
  • What is in Carla's branch that is not in main, and the other way round?
  • Who has contributed what, and how much?
  • What are the milestones — tags and merges — without the noise of the intermediate commits?

And there is a second problem, more prosaic but just as real. The invocation from the previous lesson was git blame -w -C --date=short -L :normaliseText:app.js app.js. Nobody types that twice. The second half of this lesson is about that: turning the long commands you use daily into short words, with Git aliases.

Contents

  1. --graph: seeing the shape of the history
  2. --first-parent: the mainline, without noise
  3. --simplify-by-decoration: the milestones only
  4. --merges and --no-merges
  5. Three-dot ranges and --left-right
  6. Advanced --pretty=format:: colours and alignment
  7. git shortlog: who has contributed what
  8. Composing queries with what we have learned
  9. Aliases: what they are and where they live
  10. Aliases with !: shell and functions
  11. A set of aliases for day-to-day work
  12. Risks and good practice with aliases

  1. --graph: seeing the shape of the history

By default git log gives a flat list ordered by date, which in a history with branches is misleading: you cannot see what came from where. --graph draws the DAG (lesson 03-01) with ASCII characters down the left-hand side.

git log --graph --oneline --decorate --all
*   c8f2a1e (HEAD -> main, origin/main) Merge feature/status-filter
|\
| * 4e8b2c9 (feature/status-filter) Add the filter by task status
| * 2c6e9a4 Extract the list styles into styles.css
|/
* 8d4f2a7 Delegate the list events to the container
| * 7a1f5c3 (fix/svg-delete) Use closest() when detecting the delete button
|/
* 3f1a8d6 Remove invisible characters when normalising task text
* b7e2c4a (tag: v1.0.0) Normalise task text before saving it

The three modifiers are inseparable in practice:

Option What it adds
--graph The lines and asterisks that draw the topology
--oneline One commit per line: without it, the graph is unreadable
--decorate The (HEAD -> main, tag: v1.0.0) bits: where the references point
--all All the references, not just the current branch

How to read the drawing:

  • Each * is a commit. The column it is in indicates its "lane".
  • |\ marks a merge commit: two lines come into it.
  • |/ marks the point at which two lanes join going backwards: the common ancestor.
  • The fix/svg-delete branch appears hanging off 3f1a8d6 without having been merged: those are commits that exist only in that branch.

One detail about --decorate: since Git 2.13 it has been on by default when the output goes to a terminal (--decorate=auto), so you often do not need to type it. It can be pinned with:

git config --global log.decorate short    # short | full | auto | no

And variants of --all that avoid the noise of dozens of old remote branches:

git log --graph --oneline --branches            # local branches only
git log --graph --oneline --branches --tags     # local ones + tags
git log --graph --oneline --remotes=origin      # only origin's
git log --graph --oneline main feature/status-filter   # only these two

  1. --first-parent: the mainline, without noise

It has already come up in lesson 06-02 with bisect, and in 05-06 when choosing -m 1. The idea is the same: in a merge commit, the first parent is the branch you were on (normally main) and the second is the one you brought in.

git log --oneline --first-parent main
c8f2a1e Merge feature/status-filter
8d4f2a7 Delegate the list events to the container
3f1a8d6 Remove invisible characters when normalising task text
b7e2c4a Normalise task text before saving it

Compare it with the --graph from the previous section: 4e8b2c9 and 2c6e9a4 have disappeared, which were the commits inside the merged branch. What is left is main's history at the level of integrations: each line is either a direct commit or "a whole feature came in here".

It is the right view for:

  • Drafting a version's release notes.
  • Answering "what has gone into main this week?".
  • Counting how many features were integrated in a period.
# What has gone into main since the last tag
git log --oneline --first-parent v1.0.0..main

# How many integrations in the last month
git log --oneline --first-parent --since=1.month main | wc -l

  1. --simplify-by-decoration: the milestones only

This option is little known and very useful. It shows only the commits that have a reference pointing at them (a branch, a tag or HEAD), plus the minimum needed to keep the graph connected.

git log --graph --oneline --simplify-by-decoration --all
*   c8f2a1e (HEAD -> main, origin/main) Merge feature/status-filter
|\
| * 4e8b2c9 (feature/status-filter) Add the filter by task status
* | 7a1f5c3 (fix/svg-delete) Use closest() when detecting the delete button
|/
* b7e2c4a (tag: v1.0.0) Normalise task text before saving it
* 1a5c9f3 (tag: v0.9.0) Prepare the testing version

Four hundred commits reduced to five lines: the project's skeleton. It is the first thing worth running when you arrive at an unfamiliar repository, because in ten seconds it gives you the map: which versions exist, which branches are alive and where they were born.

Useful combinations:

# Only the evolution of the versions
git log --graph --oneline --simplify-by-decoration --tags

# With dates, to see the release cadence
git log --simplify-by-decoration --tags --date=short \
  --pretty=format:'%C(yellow)%d%C(reset) %ad %s'

  1. --merges and --no-merges

Two complementary, straightforward filters:

git log --oneline --merges          # ONLY merge commits
git log --oneline --no-merges       # all of them EXCEPT the merges
Filter What it shows What it is for
--merges Only commits with two or more parents Seeing the history of integrations
--no-merges Only commits of real work Statistics, release notes, reviews
--min-parents=N Commits with at least N parents --min-parents=3 finds octopus merges
--max-parents=1 Equivalent to --no-merges The same, with different syntax
--max-parents=0 The root commits (with no parents) Detecting grafted histories

--no-merges is especially important for statistics: if you count commits per author without it, whoever merges most looks like the most productive person.

# This month's merges, with who did them
git log --merges --since=1.month --pretty=format:'%h %an %s'

# The real work since the last version, for the release notes
git log --no-merges --oneline v1.0.0..HEAD

  1. Three-dot ranges and --left-right

From lesson 02-06 we remember A..B: "the commits reachable from B but not from A". It is asymmetric and answers "what is A missing from B?".

The triple dot A...B is the symmetric difference: the commits that are in one or the other, but not in both. It answers "how have these two branches diverged?".

gitGraph
   commit id: "base"
   commit id: "M1"
   branch feature/status-filter
   commit id: "F1"
   commit id: "F2"
   checkout main
   commit id: "M2"
   commit id: "M3"
git log --oneline main..feature/status-filter     # F1, F2
git log --oneline feature/status-filter..main     # M2, M3
git log --oneline main...feature/status-filter    # F1, F2, M2, M3

With a plain A...B you cannot tell which commit is on which side. That is what --left-right is for:

git log --oneline --left-right main...feature/status-filter
> 6f2b9d4 Add the filter by task status
> a1e5c93 Extract the list styles into styles.css
< e91d4a8 Fix the pending task counter
< 7d3a8f4 Update the README with the start-up instructions
Marker Meaning
< The commit is on the left-hand side of the range (here, main)
> The commit is on the right-hand side (here, the branch)

It is the perfect view before merging or rebasing: at a glance you see how far each side has moved. And with the remote:

# How do I stand with respect to my tracking branch? (lesson 04-06)
git log --oneline --left-right --graph HEAD...@{u}

# Just the count, which is often the only thing you want
git rev-list --left-right --count main...feature/status-filter
2	2

Two commits ahead on main, two on the branch. That rev-list --count is, incidentally, what lies behind the [ahead 2, behind 2] of git status and git branch -vv.

A useful addition: --cherry-mark marks with = the commits that are equivalent to one on the other side (same change, different hash) — what we saw with git cherry in lesson 05-03:

git log --oneline --left-right --cherry-mark main...feature/status-filter

  1. Advanced --pretty=format:: colours and alignment

In lesson 02-06 we saw the basic --pretty=format: placeholders. Now we come to the two groups that turn a readable output into an excellent one: colours and alignment.

Colours

git log --pretty=format:'%C(yellow)%h%C(reset) %C(green)%ad%C(reset) %s'
Placeholder Effect
%C(red), %C(green), %C(yellow), %C(blue), %C(magenta), %C(cyan) Text colour
%C(bold blue) Colour with an attribute (bold, dim, ul, reverse)
%C(reset) Goes back to the default colour
%C(auto) Uses the colour Git would use by default for that field
%C(auto,yellow) Yellow, but only if colour is enabled
%C(always,red) Red always, even when redirecting to a file

%C(auto) is the one to use by default, and there is a specific reason: applied to %d (the decorations), it paints each reference in its canonical colour — HEAD in cyan, local branches in green, remote ones in red, tags in yellow — exactly as git log --decorate does without a custom format. Writing that by hand is impossible.

On top of that, %C(auto) respects the color.ui configuration: if you redirect the output to a file or to grep, the escape codes are not emitted and the file stays clean.

Alignment

When the format has several columns, without alignment it is a visual disaster. The padding placeholders solve it:

Placeholder Effect
%<(N) The following fields take up at least N characters, left-aligned
%<(N,trunc) The same, but truncates with .. if it overflows
%<(N,ltrunc) Truncates from the left (..end)
%<(N,mtrunc) Truncates in the middle (beg..end)
%>(N) Right-aligned
%><(N) Centred
%<|(N) Pads up to column N (absolute, not relative)

The complete format the team uses:

git log --pretty=format:'%C(auto)%h %C(blue)%<(16,trunc)%an%C(reset) %C(green)%<(12)%ar%C(reset) %C(auto)%d%C(reset) %s'
c8f2a1e Ana Ferrer      2 days ago   (HEAD -> main, origin/main) Merge feature/status-filter
4e8b2c9 Bruno Salas     3 days ago   (feature/status-filter) Add the filter by status
2c6e9a4 Carla Vidal     4 days ago   Extract the list styles into styles.css
8d4f2a7 Bruno Salas     6 weeks ago  Delegate the list events to the container
b7e2c4a Carla Vidal     5 months ago (tag: v1.0.0) Normalise task text

Breaking it down piece by piece:

  • %C(auto)%h → abbreviated hash, with Git's standard colour for hashes.
  • %C(blue)%<(16,trunc)%an%C(reset) → author in blue, exactly 16 columns, truncated if it does not fit.
  • %C(green)%<(12)%ar%C(reset) → relative date ("2 days ago") in green, 12 columns.
  • %C(auto)%d%C(reset) → the decorations, each in its canonical colour.
  • %s → the subject, taking up the rest.

The result is tabular, readable and compact. In section 11 we shall turn it into an alias.

A reminder of the most-used placeholders, so you do not have to go back to lesson 02-06:

Placeholder Content
%H / %h Full / abbreviated hash
%an / %ae Name / email of the author
%cn / %ce Name / email of the committer
%ad / %cd Authorship / commit date
%ar / %cr The same ones, in relative format
%s / %b Subject / body of the message
%d / %D Decorations with brackets / without them
%p / %P Abbreviated / full hashes of the parents
%G? GPG signature status (G good, N unsigned…)
%n / %% Line break / literal percent sign

And so as not to repeat the format every time, it can be saved by name in the configuration:

git config --global pretty.table '%C(auto)%h %C(blue)%<(16,trunc)%an%C(reset) %C(green)%<(12)%ar%C(reset) %C(auto)%d%C(reset) %s'
git log --pretty=table

  1. git shortlog: who has contributed what

git shortlog groups the commits by author. With no arguments it lists the messages grouped; with -s (summary) and -n (numbered), it gives the sorted count:

git shortlog -sn
   187	Ana Ferrer
   142	Bruno Salas
    98	Carla Vidal
     3	Infrastructure Team

Useful options:

Option Effect
-s The count only, without the messages
-n Sorts by number of commits, not alphabetically
-e Shows the email alongside the name
--no-merges Excludes merges: you nearly always want this
-c Groups by committer rather than by author
--since / --until Narrows the period
# Real contributions over the last three months
git shortlog -sn --no-merges --since=3.months

# Who has touched app.js
git shortlog -sn --no-merges -- app.js

# Release notes grouped by person
git shortlog --no-merges v1.0.0..HEAD
Ana Ferrer (12):
      Remove invisible characters when normalising task text
      Add the pending task counter
      ...

Bruno Salas (9):
      Delegate the list events to the container
      ...

That last output is, literally, a draft set of release notes.

Two important warnings, because shortlog is easily misread:

  • The number of commits does not measure productivity. A commit can be a comma or a whole feature. As a performance metric it is worse than useless: it is perverse, because it rewards artificial slicing.
  • The same person can show up several times if they have used different names or emails (personal laptop, server, web platform). This is unified with a .mailmap file at the root of the repository:
# .mailmap
Ana Ferrer <[email protected]> <[email protected]>
Bruno Salas <[email protected]> <[email protected]>
Carla Vidal <[email protected]>

Git applies it automatically in shortlog, log and blame.

  1. Composing queries with what we have learned

Everything above combines, and that is where the real power lies. Some examples that answer specific day-to-day questions about task-manager:

# What has gone into main since v1.0.0, with no merge noise,
# with who and when?
git log --no-merges --first-parent v1.0.0..main \
  --pretty=format:'%C(auto)%h %C(blue)%<(14,trunc)%an%C(reset) %ad %s' \
  --date=short
# Which commits touched app.js and mention localStorage in the diff?
# (the -S pickaxe from lesson 02-06 + format)
git log -S "localStorage" --oneline -- app.js
# The guilty commit bisect found, in its graph context
git log --graph --oneline --all --ancestry-path 8d4f2a7..main

--ancestry-path restricts the range to the commits that are on the path between the two ends, which is exactly what you want for answering "how did that commit get into main?".

# Which branches contain the guilty commit?
git branch -a --contains 8d4f2a7
# The evolution of a specific function (log -L from lesson 06-03)
# limited to the last three months
git log -L :normaliseText:app.js --since=3.months --oneline
# Unsigned commits in what I am about to push
git log --pretty=format:'%h %G? %an %s' @{u}..HEAD
# How many lines has each person changed? (with all the caveats
# from section 7 about metrics)
git log --no-merges --numstat --pretty=format:'%an' | \
  awk 'NF==1 {author=$0} NF==3 {plus[author]+=$1; minus[author]+=$2}
       END {for (a in plus) printf "%-20s +%-8d -%d\n", a, plus[a], minus[a]}'

And a comparison of the three views of the history we have seen, to have them together:

I want to see… Command
The project's complete shape git log --graph --oneline --all
main's history at the level of integrations git log --oneline --first-parent main
The milestones only (tags and branches) git log --graph --oneline --simplify-by-decoration --all
The real work, without merges git log --oneline --no-merges
How two branches have diverged git log --oneline --left-right main...other
Who has contributed how much git shortlog -sn --no-merges

Having got this far, the problem is obvious: none of these commands gets typed by hand twice.

  1. Aliases: what they are and where they live

A Git alias is a short name for a long command. It is defined with git config:

git config --global alias.st status
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.ci commit

From then on, git st is git status. Git resolves the alias before running and passes it the extra arguments:

git st --short          # equivalent to git status --short

Where they live. In the .gitconfig corresponding to the level you choose (lesson 01-05), under the [alias] section:

# ~/.gitconfig
[user]
	name = Carla Vidal
	email = [email protected]
[alias]
	st = status
	co = checkout
	br = branch
	ci = commit

They can be edited by hand in the file — which is more convenient for the long ones — or with git config. And queried:

git config --get-regexp '^alias\.'     # list them all
git config --get alias.lg              # see a specific one
git config --global --unset alias.co   # delete one

The three levels work exactly as for the rest of the configuration: --system, --global and local. Personal aliases go in --global; an alias specific to a project, in the local one. And remember that configuration is not cloned: the aliases are yours, not the repository's.

Two rules that avoid surprises:

  • An alias cannot override an existing command. If you define alias.status, Git will ignore the alias and run the real command. It is a deliberate protection.
  • Aliases can be chained, but with care: an alias that invokes another one works, and a recursive alias hangs Git.

  1. Aliases with !: shell and functions

An ordinary alias can only start with a Git subcommand. For everything else — chaining commands, using pipes, invoking external programs, placing the arguments somewhere other than at the end — you prefix !, which tells Git: "this is a shell command, run it as it is".

git config --global alias.cleanup '!git branch --merged main | grep -v main | xargs -r git branch -d'
git cleanup

Three fundamental things about aliases with !:

  1. They run from the ROOT of the repository

This is the main risk and the number-one source of surprises. Git does a cd to the root of the working tree before running the alias. So if you are in ~/projects/task-manager/components/ and you run an alias with !ls, you will see the content of ~/projects/task-manager/, not that of the directory you are in.

Git provides a variable to mitigate it:

git config --global alias.here '!f() { cd "${GIT_PREFIX:-.}" && ls -la; }; f'

GIT_PREFIX contains the path relative to the root of the directory you were in when you invoked the alias (empty if you were already at the root). Any alias with ! that works with relative paths must take it into account:

git config --global alias.addhere '!f() { cd "${GIT_PREFIX:-.}" && git add "$@"; }; f'

  1. The arguments are appended at the end… unless you use a function

Git sticks the arguments you type at the end of the alias's command. That is a problem when you need them in the middle:

# WRONG: 'git find hello' runs 'git log --oneline | grep  hello'
git config --global alias.find '!git log --oneline | grep'

In this case it works by coincidence. But if the argument goes in the middle, it does not:

# WRONG: 'git since main' runs 'git log --oneline ..HEAD main'
git config --global alias.since '!git log --oneline ..HEAD'

The canonical solution is to define a shell function and call it:

git config --global alias.since '!f() { git log --oneline "$1"..HEAD; }; f'
git since main       # → git log --oneline main..HEAD

The pattern '!f() { ...; }; f' reads like this: define a function called f, and then invoke it. The arguments Git sticks on the end become f's arguments. It is the standard idiom and you will see it in every .gitconfig in the world.

Use "$@" to pass all the arguments and "$1", "$2" for specific positions. And always quote them: without quotes, a branch name with odd characters breaks the alias.

  1. They run with sh, not with bash

Git uses /bin/sh. On many systems that is dash, not bash, and the bash extensions ([[ ]], arrays, mapfile) do not work. Write aliases in POSIX shell or invoke the interpreter explicitly:

git config --global alias.complex '!bash -c '"'"'... bash script ...'"'"''

For anything longer than three lines, the right answer is not an alias but a script on the PATH. Git has a lovely convention for that: any executable called git-<something> that is on the PATH can be invoked as git <something>:

# ~/bin/git-summary  (with chmod +x)
#!/usr/bin/env bash
set -euo pipefail
echo "=== Current branch ==="
git branch --show-current
echo
echo "=== Last 10 commits ==="
git log --oneline -10
echo
echo "=== Status ==="
git status --short
git summary

Without configuring anything. It is the right route for complex logic, and as a bonus the script can be version-controlled and shared.

  1. A set of aliases for day-to-day work

These are the ones that genuinely earn their place. Starting with the absolute classic:

git config --global alias.lg "log --graph --abbrev-commit --decorate --all --pretty=format:'%C(auto)%h%C(reset) -%C(auto)%d%C(reset) %s %C(green)(%ar)%C(reset) %C(blue)<%an>%C(reset)'"
git lg
*   c8f2a1e - (HEAD -> main, origin/main) Merge feature/status-filter (2 days ago) <Ana Ferrer>
|\
| * 4e8b2c9 - (feature/status-filter) Add the filter by status (3 days ago) <Bruno Salas>
| * 2c6e9a4 - Extract the list styles into styles.css (4 days ago) <Carla Vidal>
|/
* 8d4f2a7 - Delegate the list events to the container (6 weeks ago) <Bruno Salas>
* b7e2c4a - (tag: v1.0.0) Normalise task text (5 months ago) <Carla Vidal>

lg is probably the most copied alias in the history of Git, and with good reason: it replaces a 180-character command that nobody remembers.

The complete table of the recommended set:

Alias Definition What for
st status --short --branch Compact status with the branch and its tracking
lg The one above The complete graph, coloured
lgd log --graph --oneline --decorate The same but only the current branch
last log -1 HEAD --stat The last commit with its files
recent log --oneline -20 The last 20, nothing more
milestones log --graph --oneline --simplify-by-decoration --all The project's skeleton
integrated log --oneline --first-parent main's mainline
who shortlog -sn --no-merges Contributions per person
praise blame -w -C --date=short The blame from lesson 06-03, properly configured
history !f() { git log -L :"$1":"$2"; }; f git history normaliseText app.js
pending !git log --oneline @{u}..HEAD What I have not pushed
incoming !git fetch -q && git log --oneline HEAD..@{u} What is on the remote and not fetched
divergence !f() { git log --oneline --left-right "$1"...HEAD; }; f How I have diverged from a branch
wip !git add -A && git commit -m 'WIP: work in progress' --no-verify Quick save before switching context
undo reset HEAD~1 --mixed Undoes the last commit keeping the changes
amend commit --amend --no-edit Adds what is staged to the last commit
cleanup !git branch --merged main | grep -vE '^\*|main' | xargs -r git branch -d Deletes the already-merged branches
branches branch -vv --sort=-committerdate Branches by recent activity, with tracking
saved stash list --pretty=format:'%C(yellow)%gd%C(reset) %s' The stash stack, readable
tags tag -l --sort=-v:refname --format='%(refname:short) %(creatordate:short) %(subject)' Tags by descending version
alias !git config --get-regexp '^alias\.' | sed 's/^alias\.//' Remembering which aliases I have

That last one is more useful than it looks: after six months nobody remembers all their aliases.

To install them in one go, it is more convenient to edit the file than to fire off twenty git configs:

git config --global --edit
[alias]
	st = status --short --branch
	lg = log --graph --abbrev-commit --decorate --all --pretty=format:'%C(auto)%h%C(reset) -%C(auto)%d%C(reset) %s %C(green)(%ar)%C(reset) %C(blue)<%an>%C(reset)'
	milestones = log --graph --oneline --simplify-by-decoration --all
	integrated = log --oneline --first-parent
	who = shortlog -sn --no-merges
	praise = blame -w -C --date=short
	history = "!f() { git log -L :\"$1\":\"$2\"; }; f"
	pending = "!git log --oneline @{u}..HEAD"
	divergence = "!f() { git log --oneline --left-right \"$1\"...HEAD; }; f"
	undo = reset HEAD~1 --mixed
	amend = commit --amend --no-edit
	branches = branch -vv --sort=-committerdate
	alias = "!git config --get-regexp '^alias\\.' | sed 's/^alias\\.//'"

Note the escaping: in the file, values with quotes or backslashes go inside double quotes and with the backslashes doubled. It is the most annoying part of aliases with !, and the reason it is worth testing them right after writing them.

And this connects with the earlier modules: aliases are not just for log. pending uses @{u} from lesson 04-06; amend is the --amend from 02-04; cleanup is the branch management from 03-06; praise is the blame from 06-03. An alias is the way of fixing into one gesture what you have learned to do properly, so that you do not have to remember the seven options every time.

  1. Risks and good practice with aliases

Risk 1: the directory it runs from. Already seen: aliases with ! run from the root of the repository. Use GIT_PREFIX if the alias works with paths.

Risk 2: destructive aliases with short names. A git p that does push --force is an accident waiting to happen. Rule: the more destructive, the longer the name. And in the case of push --force, always use --force-with-lease (lesson 04-05).

Risk 3: dependence on your machine. Your aliases are not on the laptop of the colleague you ask for help, nor on the server. It is worth knowing the real command behind each one.

Risk 4: sh is not bash. The bash extensions fail silently or with odd errors.

Risk 5: aliases that hide effects. A git sync that does fetch + rebase + push is fine until the day there are conflicts and you cannot work out where the process has stopped.

Good practice:

  • Let the name say what it does. pending is better than p.
  • Version-control your .gitconfig. Many people keep their dotfiles in a repository of their own: aliases are valuable configuration and recreating them is a chore.
  • Add aliases little by little. When you notice you are typing the same thing for the third time, there you have a candidate. Copying a list of forty aliases off the internet guarantees you will use none of them.
  • Test the alias as soon as you create it, especially the ones with ! and quotes.
  • For complex logic, git-<name> on the PATH, not a three-line alias.

Common Mistakes and Tips

Mistake 1: --graph without --oneline. The graph with full messages is unreadable. They always go together.

Mistake 2: forgetting --all and believing the branch does not exist. git log --graph only shows the current branch. Without --all, you do not see the others.

Mistake 3: confusing .. with .... A..B is asymmetric ("what A is missing"); A...B is symmetric ("how they have diverged"). With --left-right the ambiguity disappears.

Mistake 4: counting commits with shortlog without --no-merges. Whoever integrates most looks like the one who produces most.

Mistake 5: using the commit count as a performance metric. It is a bad metric and it creates perverse incentives.

Mistake 6: an alias with ! that assumes the current directory. It runs from the root. GIT_PREFIX.

Mistake 7: arguments in the middle of an alias without using a function. Git sticks them at the end. The pattern '!f() { ...; }; f' is the solution.

Mistake 8: badly done escaping in the .gitconfig. Double quotes around the value and doubled backslashes. Test the alias immediately.

Tip 1: %C(auto) by default in your formats. It colours the decorations correctly and respects both the configuration and redirections.

Tip 2: save formats by name. git config --global pretty.table '...' and then git log --pretty=table.

Tip 3: --simplify-by-decoration when you arrive at a new repository. Ten seconds for the complete map.

Tip 4: git rev-list --count A...B for the pure count. When you only want to know how much, not what.

Tip 5: a .mailmap as soon as somebody shows up duplicated. It is a three-line file that fixes shortlog, log and blame all at once.

Exercises

Exercise 1: reading the shape of a history

Create a repository with this topology: four commits on main, a feature/a branch with two commits merged with --no-ff, a feature/b branch with two commits not merged, and a v1.0 tag on main's second commit. Then:

  1. Show the complete graph with --graph --oneline --decorate --all.
  2. Show only the mainline with --first-parent.
  3. Show only the milestones with --simplify-by-decoration --all.
  4. Show only the merges, and then only the commits that are not merges.
  5. Explain which commits disappear in each view and why.

Exercise 2: comparing two branches

On the same repository:

  1. Add two more commits to main and two more to feature/b.
  2. Use main..feature/b and feature/b..main, and explain the difference.
  3. Use main...feature/b with --left-right and identify each side.
  4. Get the count with git rev-list --left-right --count.
  5. Create a --pretty format with colours and alignment that shows hash, author (14 truncated columns), short date and subject.

Exercise 3: building your aliases

  1. Create the lg, who, pending and praise aliases from the table in section 11.
  2. Create a history alias that accepts two arguments (function and file) and runs git log -L. Test it.
  3. Create an alias that demonstrates the GIT_PREFIX problem: one that just runs ls, and another that does it properly. Run them from a subdirectory and compare.
  4. List all your aliases with an alias.

Solutions

Solution 1:

mkdir /tmp/practice-log && cd /tmp/practice-log
git init -b main

for i in 1 2; do echo "main $i" >> main.txt; git add .; git commit -q -m "Commit main $i"; done
git tag v1.0

git switch -qc feature/a
echo "a1" > a.txt && git add . && git commit -q -m "Feature A part 1"
echo "a2" >> a.txt && git commit -qam "Feature A part 2"

git switch -q main
echo "main 3" >> main.txt && git commit -qam "Commit main 3"
git merge -q --no-ff feature/a -m "Merge feature/a"
echo "main 4" >> main.txt && git commit -qam "Commit main 4"

git switch -qc feature/b v1.0
echo "b1" > b.txt && git add . && git commit -q -m "Feature B part 1"
echo "b2" >> b.txt && git commit -qam "Feature B part 2"
git switch -q main
git log --graph --oneline --decorate --all
* 9c4e7b2 (HEAD -> main) Commit main 4
*   5f1d8a3 Merge feature/a
|\
| * 2a8c6f9 (feature/a) Feature A part 2
| * 7d3e1b5 Feature A part 1
* | 4b9f2c8 Commit main 3
|/
| * 8e5a3d7 (feature/b) Feature B part 2
| * 1c7b4f9 Feature B part 1
|/
* 3a6d9e2 (tag: v1.0) Commit main 2
* 6f2c8b1 Commit main 1
git log --oneline --first-parent main
9c4e7b2 Commit main 4
5f1d8a3 Merge feature/a
4b9f2c8 Commit main 3
3a6d9e2 Commit main 2
6f2c8b1 Commit main 1

2a8c6f9 and 7d3e1b5 have disappeared: they are the commits inside feature/a, which are only reached through the merge's second parent.

git log --graph --oneline --simplify-by-decoration --all
* 9c4e7b2 (HEAD -> main) Commit main 4
| * 8e5a3d7 (feature/b) Feature B part 2
|/
| * 2a8c6f9 (feature/a) Feature A part 2
|/
* 3a6d9e2 (tag: v1.0) Commit main 2

Only the commits with a reference and the minimum needed to connect them: the skeleton.

git log --oneline --merges
git log --oneline --no-merges
5f1d8a3 Merge feature/a
9c4e7b2 Commit main 4
2a8c6f9 Feature A part 2
7d3e1b5 Feature A part 1
4b9f2c8 Commit main 3
3a6d9e2 Commit main 2
6f2c8b1 Commit main 1

Solution 2:

echo "main 5" >> main.txt && git commit -qam "Commit main 5"
echo "main 6" >> main.txt && git commit -qam "Commit main 6"
git switch -q feature/b
echo "b3" >> b.txt && git commit -qam "Feature B part 3"
echo "b4" >> b.txt && git commit -qam "Feature B part 4"
git switch -q main
git log --oneline main..feature/b
d4a8f2c Feature B part 4
b1e6c9f Feature B part 3
8e5a3d7 Feature B part 2
1c7b4f9 Feature B part 1

What the branch has and main does not: what would come in on merging.

git log --oneline feature/b..main
7f3c2e8 Commit main 6
2d9b5a1 Commit main 5
9c4e7b2 Commit main 4
5f1d8a3 Merge feature/a
2a8c6f9 Feature A part 2
7d3e1b5 Feature A part 1
4b9f2c8 Commit main 3

What the branch is missing from main: what it would bring in on rebasing or merging main into it.

git log --oneline --left-right main...feature/b
< 7f3c2e8 Commit main 6
< 2d9b5a1 Commit main 5
< 9c4e7b2 Commit main 4
< 5f1d8a3 Merge feature/a
< 2a8c6f9 Feature A part 2
< 7d3e1b5 Feature A part 1
< 4b9f2c8 Commit main 3
> d4a8f2c Feature B part 4
> b1e6c9f Feature B part 3
> 8e5a3d7 Feature B part 2
> 1c7b4f9 Feature B part 1
git rev-list --left-right --count main...feature/b
7	4
git log --pretty=format:'%C(auto)%h %C(blue)%<(14,trunc)%an%C(reset) %C(green)%ad%C(reset) %s' --date=short -8

Solution 3:

git config --global alias.lg "log --graph --abbrev-commit --decorate --all --pretty=format:'%C(auto)%h%C(reset) -%C(auto)%d%C(reset) %s %C(green)(%ar)%C(reset) %C(blue)<%an>%C(reset)'"
git config --global alias.who "shortlog -sn --no-merges"
git config --global alias.pending "!git log --oneline @{u}..HEAD"
git config --global alias.praise "blame -w -C --date=short"
git config --global alias.history '!f() { git log -L :"$1":"$2"; }; f'
git config --global alias.alias "!git config --get-regexp '^alias\\.' | sed 's/^alias\\.//'"
cd /tmp/practice-log
git lg
git who
   4	Carla Vidal
   ...

The alias with arguments:

cat > functions.js <<'END'
function greet(name) {
  return "Hello " + name;
}
END
git add . && git commit -q -m "Add the greeting function"
sed -i 's/"Hello "/"Hello there, "/' functions.js
git commit -qam "Make the greeting friendlier"

git history greet functions.js
commit 4e9c2a7
    Make the greeting friendlier
...

The GIT_PREFIX problem:

mkdir -p components && echo "x" > components/button.js
git add . && git commit -q -m "Add the button component"

git config --global alias.bad '!ls'
git config --global alias.good '!f() { cd "${GIT_PREFIX:-.}" && ls; }; f'

cd components
git bad
components  functions.js  main.txt  a.txt  b.txt

It has listed the root of the repository, not the directory we are in.

git good
button.js

With GIT_PREFIX, the alias respects the directory it was invoked from.

git alias
alias	!git config --get-regexp '^alias\.' | sed 's/^alias\.//'
bad	!ls
good	!f() { cd "${GIT_PREFIX:-.}" && ls; }; f
history	!f() { git log -L :"$1":"$2"; }; f
lg	log --graph --abbrev-commit --decorate --all --pretty=format:...
pending	!git log --oneline @{u}..HEAD
praise	blame -w -C --date=short
who	shortlog -sn --no-merges
git config --global --unset alias.bad      # cleaning up

Conclusion

This lesson has turned git log from a listing into a querying tool, and has removed the friction of using it. The essentials:

  • --graph --oneline --decorate --all is the canonical view of the topology. All four go together.
  • --first-parent gives main's history at the level of integrations, without the insides of the merged branches. It is the same "first parent" as revert's -m 1 and bisect --first-parent.
  • --simplify-by-decoration reduces the history to its milestones: the first thing to run in an unfamiliar repository.
  • --merges / --no-merges separate integrations from real work; --no-merges is all but mandatory in any statistic.
  • A...B with --left-right answers "how have these two branches diverged?", with < and > marking each side. git rev-list --left-right --count gives just the count.
  • --pretty=format: with %C(auto) and the alignment placeholders %<(N,trunc) produces tabular, coloured output; save them by name in pretty.<name>.
  • git shortlog -sn --no-merges summarises the contributions, with .mailmap to unify identities — and with the warning that counting commits measures nothing useful.
  • Aliases live in the [alias] section of the .gitconfig at whichever level you choose. The simple ones are a name for a subcommand; the ones starting with ! are shell, with three cautions: they run from the root (use GIT_PREFIX), the arguments are stuck on the end (use the pattern '!f() { ...; }; f') and they run under sh, not bash.
  • For long logic, an executable git-<name> on the PATH is better than an alias.
  • And the underlying idea: an alias fixes into one gesture what you have learned to do properly. praise, pending, milestones or cleanup are the previous lessons condensed into a single word.

The team now knows how to interrogate its history comfortably. But task-manager is about to stop being a single repository: the buttons, dialogs and form fields that Ana has gradually been extracting have turned into an internal library, ui-components, which lives at git.example.com/team/ui-components.git and which two other company projects also want to use.

The question is how a project is composed out of several repositories without copying and pasting code, and without losing the traceability of which version of the library each version of the application uses. Git's native answer is submodules, and that is lesson 06-05: Git Submodules.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved