In the previous lesson we went through four very different projects and in all four the same thing turned up: Git never works on its own. Mailing lists appeared, and proposal platforms, build systems, integration pipelines and tracking tools. Git is the engine, but the daily experience of using it is defined by everything around it.
This lesson is about that layer. Not about new commands — apart from a couple of pieces that were missing — but about how Git fits into a real working setup: the editor, the terminal, the ticketing system, the linters, the review platform. And about where the risk lies, which is not technical but a matter of understanding.
Because there is a pattern that repeats in every team: somebody has been using Git from a button in their editor for two years, everything goes well, and the day something goes wrong they discover they do not know what that button did. That "Sync" that solved everything was git pull --rebase followed by git push, and now there are seventeen rewritten commits, a published branch that has diverged and no idea where to begin.
You already know how to get out of that (the whole of module 9). What we are going to do here is use the tools without giving up on understanding them: take advantage of what they genuinely contribute, know which command they run underneath and be clear about what is worth carrying on doing by hand.
Contents
- The underlying rule: the tool does not replace the mental model
- Editors and IDEs: what they genuinely contribute
- What is worth carrying on doing in the terminal
- Graphical clients and history viewers
- The terminal as a Git environment: prompt and completion
- Ticketing systems: closing the
GT-NNNconvention - Quality tools: linters, formatters and
.editorconfig - Review and analysis: status checks
format-patch,amandrequest-pullas a bridge between worlds- How to choose what to automate
- The underlying rule: the tool does not replace the mental model
Before anything else, the rule that orders the whole lesson:
Every tool that integrates Git ends up running Git commands. If you know which command it runs, the tool saves you time. If you do not, the tool saves you time until the day it fails, and then it costs you far more than it saved you.
This is not an argument against graphical interfaces. It is an argument in favour of knowing how to read what they do. Nearly all of them offer a console or a log where they show the commands they have run: looking at it during the first few weeks is the best learning investment there is.
And there is an important asymmetry. The tools are excellent at the visual and repetitive:
- Seeing differences in colour, by word, side by side.
- Resolving a conflict with three panels on screen.
- Staging changes hunk by hunk with the mouse.
- Seeing the commit graph drawn out.
- Seeing who wrote each line without leaving the file.
And they are bad — or downright dangerous — at the conceptual and irreversible:
- Anything that rewrites history.
- Resolving a divergence with the remote.
- Recovering from a mistake.
- Any operation where you want to understand exactly what happened.
The practical rule that comes out of that, and which is the summary of the whole module:
Use the interface to look. Use the terminal to decide.
- Editors and IDEs: what they genuinely contribute
Modern editors come with Git integration built in. What follows is what is genuinely worth having, ordered by return on investment.
Visual differences in the editor itself
The editor marks in the gutter the lines added, modified and deleted relative to HEAD, and lets you see the previous version at a glance. It is the equivalent of git diff (lesson 02-05) but without switching context: you see the change inside the file you are editing, with the language's syntax highlighting.
It is probably the most valuable integration there is, because it changes a habit: instead of reviewing the changes at the end, you see them as you work.
What it contributes: immediate context, word-level differences within a line, reverting a specific hunk with one click.
What it does not replace: git diff --staged before committing. It is still the final quality control, and it is worth doing with the full view.
Conflict resolution with three panels
When there is a conflict (lesson 03-05), a good editor shows three versions: yours, the other branch's and the common base. Being able to see the base is what makes the difference, because a conflict is only understood by knowing what the two sides started from.
This can be configured so that Git invokes it directly:
# Register a merge tool
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'
# Do not leave .orig files scattered around the disk
git config --global mergetool.keepBackup false
# Use it when there is a conflict
git mergetoolAnd the equivalent for viewing differences:
git config --global diff.tool vscode
git config --global difftool.vscode.cmd 'code --wait --diff $LOCAL $REMOTE'
git config --global difftool.prompt false
git difftool HEAD~1One setting that is always worth switching on, and which we already saw in lesson 03-05:
With zdiff3, the conflict markers include the base version between ||||||| and =======, not just the two sides. It is the same information the three panels give, but in the file, and it works whether you resolve in the editor or by hand.
Inline blame
The editor shows at the end of each line who wrote it, when and with what commit message. It is git blame (lesson 06-03) turned into ambient information.
Its virtue is that it removes the friction of wondering why. When reading the reason for a line costs nothing, it gets read; when it costs a command and a change of window, it does not.
Its danger is the one we already discussed: blame gives you the last person who touched the line, who is very often whoever re-indented it. When the result makes no sense, you have to go to the terminal:
# Ignore whitespace changes and code movements
git blame -w -M -C app.js
# Ignore the mass reformatting commits listed in a file
git blame --ignore-revs-file .git-blame-ignore-revs app.jsThat .git-blame-ignore-revs file at the root of the repository is a list of hashes of purely cosmetic commits:
# Global reformatting with the new formatter (GT-198) 7c4d9e2f1a3b5c6d8e0f2a4b6c8d0e2f4a6b8c0d # Migration from single to double quotes (GT-203) 2f8a1c4e6b9d0f3a5c7e9b1d3f5a7c9e1b3d5f7a
And it can be left permanently active:
Editors respect that setting, so the inline blame stops lying. It is a two-minute adjustment that improves life for the whole team.
Staging hunks with the mouse
git add -p (lesson 02-04) is one of the most useful tools in the course and one of the most uncomfortable to use: you have to decide y/n/s/e on hunks that sometimes have to be split by hand. In an editor, selecting the lines that go into the index is a matter of clicks, and splitting a hunk is trivial.
Here the graphical interface wins clearly. If the e in add -p (editing the hunk by hand in diff format) feels hostile to you, this is its reasonable alternative.
The rest: useful but secondary
- File history:
git log --follow -p file, with convenient navigation. - Branch list and switching between them: convenient, but it is worth knowing that the editor may be running
git switchorgit checkoutwith different nuances. - Stash from the menu: equivalent to
git stash push, almost never with a message. A stash without a message is a stash whose contents you will not know in three days' time (lesson 05-04).
Correspondence table
Useful for always knowing what is underneath the button:
| What you see in the editor | The command underneath | Watch out for |
|---|---|---|
| "Sync changes" | git pull (+ --rebase depending on config) and then git push |
It can rewrite your commits without warning |
| "Publish branch" | git push -u origin <branch> |
Nothing, it is safe |
| "Commit" | git commit (sometimes with an implicit -a) |
The implicit -a commits things you did not want |
| "Commit and push" | git commit + git push |
Committing and publishing in one gesture: no convenient way back |
| "Discard changes" | git restore <file> |
Irreversible: there is no reflog for uncommitted changes |
| "Undo last commit" | git reset --soft HEAD~1 (normally) |
Check whether it is --soft, --mixed or --hard |
| "Update branch" | git pull or git fetch + git merge/rebase |
Depending on the config, it produces merges or rewrites |
| "Resolve in the editor" | git mergetool or direct editing |
Remember to git add the resolved file |
The most dangerous one in the table is the first. "Sync" is a button that performs two network operations, one of which may reorder your local history according to a global setting you perhaps put there a year ago. It is worth looking once at exactly what it does:
- What is worth carrying on doing in the terminal
The other half of the rule. These operations are best done by typing the command, and not out of purism:
| Operation | Why in the terminal |
|---|---|
Rewriting history (rebase -i, commit --amend, filter-repo) |
You need to see exactly which commits are being touched. The interface hides the plan. |
| Resolving divergences with the remote (09-03) | It requires deciding between merge, rebase or --force-with-lease. It is not a button. |
Recovering with reflog (09-04) |
Hardly any interface exposes it well, and it is the main safety net. |
push --force-with-lease |
Many interfaces offer plain --force, which is the dangerous variant. |
Debugging (GIT_TRACE, check-ignore, plumbing) (09-06) |
There is no graphical equivalent. |
bisect (06-02) |
Automatable with --run, impossible to automate with clicks. |
Precise log queries (06-04) |
-S, -G, --follow, -L :function:file: expressiveness no interface matches. |
| Anything you do not understand | If you do not know what is going to happen, type the command and read the output. |
The last row is the important one. The mistake to avoid is not using the interface: it is using it for something you do not understand. If you know exactly what a button does, press the button; it saves you typing. If you do not, type the command, even if it takes longer, because you are paying the learning cost only once.
- Graphical clients and history viewers
Dedicated graphical clients go a step further than an editor's integration: they are applications whose only job is Git.
Where they are genuinely superior
The graph. A history with parallel branches and merges is a directed acyclic graph (lesson 03-01), and a graph is much better understood drawn than in text. Seeing at a glance where a branch diverged, what merges there were and which commits belong to whom is genuinely faster in an interface.
In the terminal, the decent equivalent:
And with the formats from lesson 06-04, very readable:
But with more than three or four live branches, the ASCII drawing becomes hard to follow. There an interface wins.
Comparing two arbitrary commits. Selecting two points in the history and seeing the difference is more convenient with the mouse than by remembering two hashes.
Exploring somebody else's repository. When you arrive fresh at a project, going through its history visually gives you an intuition for how the team works that is harder to get from reading text.
Git brings its own
Without installing anything, Git includes two minimal graphical tools:
They are austere, but they are everywhere and do not depend on anybody maintaining them. gitk --all --date-order is still a quick way of looking at a graph on a server where you are not going to install anything.
The specific risk of graphical clients
Their ease makes costly operations get run without thinking. Dragging one branch onto another may launch a rebase of twenty commits. A context menu may offer "Force push" right next to "Push", without distinguishing between --force and --force-with-lease (lesson 09-03).
Specific advice: if your graphical client offers force pushing, check in its settings which of the two variants it uses. If it is plain --force and it cannot be changed, do that operation in the terminal. The difference between the two is a colleague's work.
- The terminal as a Git environment: prompt and completion
If you are going to decide in the terminal, it is worth having the terminal well equipped. Two settings with an enormous effort-to-benefit ratio.
Git status in the prompt
Seeing the current branch and the state of the tree on every line of the terminal eliminates a whole class of mistakes: committing on the wrong branch, forgetting that there is a half-finished rebase, not noticing that there are uncommitted changes.
Git ships the official script. It is usually already installed with Git:
# Find it (the path varies by system)
ls /usr/share/git/completion/git-prompt.sh 2>/dev/null
ls /usr/share/git-core/contrib/completion/git-prompt.sh 2>/dev/null
ls /usr/lib/git-core/git-sh-prompt 2>/dev/nullAnd it is configured in ~/.bashrc:
# Load Git's prompt script
source /usr/lib/git-core/git-sh-prompt
# What information to show
export GIT_PS1_SHOWDIRTYSTATE=1 # * if there are changes, + if there are staged ones
export GIT_PS1_SHOWSTASHSTATE=1 # $ if there is something in the stash
export GIT_PS1_SHOWUNTRACKEDFILES=1 # % if there are untracked files
export GIT_PS1_SHOWUPSTREAM="auto" # < > = relative to the remote
export GIT_PS1_SHOWCOLORHINTS=1 # colour according to the state
# The prompt
PS1='\u@\h:\w$(__git_ps1 " (%s)")\$ 'The result in practice:
ana@laptop:~/task-manager (main=)$ ana@laptop:~/task-manager (GT-231-hidden-filter *%<)$ ana@laptop:~/task-manager (GT-231-hidden-filter|REBASE 2/5)$
Read them from left to right:
main=: onmain, in sync with the remote.GT-231-hidden-filter *%<: there are unstaged changes (*), untracked files (%) and the remote is ahead (<), so afetchis due.|REBASE 2/5: there is a rebase half-finished, at step 2 of 5. This is the one that prevents the most upsets: it is exactly the situation in which somebody gets up for a coffee, comes back and does not understand whygit statusis saying strange things (lesson 09-01).
On macOS with zsh, Bruno has the equivalent built into his shell configuration; and on Windows, Carla has it out of the box in Git Bash. What matters is not which script you use, but that the branch should always be in sight.
Completion
Git also ships its completion script, which completes subcommands, options, branch, tag and remote names:
With it, git switch GT-<TAB> offers the branches beginning with GT-. In a repository with many branches it is an enormous improvement, and it also prevents the mistake of mistyping a branch name, which in Git sometimes does not fail but creates something new instead.
A valuable detail: completion also works with your aliases if they are defined as Git aliases (lesson 06-04):
git config --global alias.hist "log --graph --oneline --decorate --all"
git config --global alias.st "status -sb"
git config --global alias.last "log -1 HEAD --stat"Compared with shell aliases (alias gs='git status'), Git aliases have three advantages: they travel with your configuration to any machine, they work the same in Bash, zsh and Git Bash, and they are visible to anybody who reads your configuration. Shell aliases are shorter to type; use both, each for its own purpose.
- Ticketing systems: closing the
GT-NNN convention
GT-NNN conventionWe have spent the whole course writing GT-231 in branches and messages. It is time to explain exactly what it is for and what it achieves.
The three points of contact
flowchart LR
T["Ticket GT-231<br/>in the tracker"] --> R["Branch<br/>GT-231-hidden-filter"]
R --> C["Commits<br/>feat: GT-231 ..."]
C --> P["Proposal<br/>'Closes GT-231'"]
P --> M["Merge into main"]
M --> T2["Ticket closed<br/>automatically"]
M --> V["Tag v1.4.0<br/>changelog with GT-231"]
The complete traceability chain has three links that have to be kept up by hand and one that is automated:
- The branch carries the identifier.
GT-231-hidden-filter. Without this, in six months' time nobody will know what that branch was. - The commits carry it. With Conventional Commits (lesson 08-01), in the subject or in a trailer:
feat(tasks): GT-231 filter hidden tasks out of the list Archived tasks no longer appear in the main list. The `hidden` flag is added and filtered on in renderTasks(). Refs: GT-231
- The proposal declares it, with the keyword the platform recognises (
Closes GT-231,Fixes GT-231,Resolves GT-231, depending on the tool). - On integration, the ticket closes itself and ends up linked to the integration commit.
The question this answers
Traceability is not bureaucracy. It is the ability to answer, in thirty seconds and without asking anybody, these four questions:
| Question | How it is answered |
|---|---|
"What changed with GT-231?" |
Search for the identifier in the history |
| "Why is this line the way it is?" | git blame → commit → ticket → the full discussion |
"Is GT-231 in production?" |
Is its commit in the deployed tag? |
| "What is going into version 1.4.0?" | The tickets of the commits between v1.3.0 and v1.4.0 |
And the corresponding commands, all of them already familiar:
# Which commits mention GT-231?
git log --grep='GT-231' --oneline
# Which branches and tags is that commit in?
git branch -a --contains 4f8a2e6
git tag --contains 4f8a2e6
# Which tickets are going into the next version?
git log v1.3.0..main --format=%s | grep -oE 'GT-[0-9]+' | sort -u
# Is GT-231 in the tag that is in production?
git merge-base --is-ancestor 4f8a2e6 v1.3.0 && echo "yes" || echo "no"That last command is the one that genuinely gets used when somebody writes "this is still failing in production": it checks objectively whether the fix ever got there.
Automating the tedious half
Writing GT-231 in every message gets forgotten. It can be automated with a hook (lesson 06-01) that extracts it from the branch name:
#!/bin/sh
# .git/hooks/prepare-commit-msg
# Adds the ticket identifier, taken from the branch name.
MSG_FILE="$1"
SOURCE="$2"
# Do not touch merges, squashes or messages already written with an edited -m
case "$SOURCE" in
merge|squash) exit 0 ;;
esac
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null) || exit 0
TICKET=$(printf '%s' "$BRANCH" | grep -oE '^GT-[0-9]+')
[ -z "$TICKET" ] && exit 0
grep -q "$TICKET" "$MSG_FILE" && exit 0
# Insert the ticket at the start of the first line
sed -i.bak "1s/^/$TICKET /" "$MSG_FILE" && rm -f "$MSG_FILE.bak"And the server-side validation, which is what guarantees compliance, we already saw in lesson 06-01 as an update hook and in 07-06 as a pipeline check.
Remember the division of responsibilities we established: the client hook helps, the server hook enforces. A prepare-commit-msg is a convenience; if the requirement is real, it has to be checked on the server or in CI as well, because local hooks are not distributed with the repository and anybody can skip them with --no-verify.
- Quality tools: linters, formatters and
.editorconfig
.editorconfigA linter points out problems; a formatter imposes a style. In a team, both should run automatically, because style argued over in reviews is wasted time.
The interesting question is where to hook them in, and there are three possible places.
The three positions
| Where | When it acts | Advantage | Drawback |
|---|---|---|---|
| Editor (on save) | Instant | You never see the error | Depends on each person's configuration |
Client hook (pre-commit) |
On committing | Prevents the dirty commit being created | Not distributed; skipped with --no-verify; slows you down |
| CI (07-06) | On pushing / in the proposal | Genuinely mandatory, the same for everybody | Slow cycle: you find out minutes later |
The correct combination is editor + CI, with the hook as an option for whoever wants it. The editor gives you the immediate answer, CI guarantees compliance. The pre-commit hook is convenient but cannot be the only defence, for the usual reason: it does not travel with the repository.
A well-written pre-commit has one detail that almost everybody forgets: it must check what is in the index, not what is on disk. If you have unstaged changes, checking the file on disk validates something that is not what you are about to commit:
#!/bin/sh
# .git/hooks/pre-commit
# Checks the format ONLY of what is going into the commit.
# Set aside the unstaged changes so as not to validate them
git stash push --keep-index --include-untracked --quiet --message "pre-commit"
RESTORE=$?
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|css)$')
CODE=0
if [ -n "$FILES" ]; then
echo "$FILES" | xargs npx eslint || CODE=1
fi
# Always restore, whether the check fails or not
[ $RESTORE -eq 0 ] && git stash pop --quiet
exit $CODEThree things that deserve attention:
--diff-filter=ACMexcludes deleted files: there is no point running the linter over something that no longer exists. It is the most frequent mistake in these hooks.- The
stash push --keep-indexsets aside what is not staged (lesson 05-04) so that the linter sees exactly the content that is going to be committed. - The restore happens whether or not the check passes. A hook that leaves your work in the stash when it fails is a hook people switch off.
.editorconfig
A small and very profitable file, which travels in the repository and which almost every editor respects (some out of the box, others with an extension):
# .editorconfig at the root of task-manager
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{bat,cmd}]
end_of_line = crlf
[Makefile]
indent_style = tabIts value lies in solving at the source what .gitattributes (lesson 08-04) solves at the repository boundary. They are complementary, and the division is worth being clear about:
.editorconfig |
.gitattributes |
|
|---|---|---|
| Who applies it | The editor, when writing the file | Git, when committing and checking out |
| When | While you edit | On add/commit/checkout |
| If it is not supported | Nothing happens, it is ignored | Git always applies it |
| Scope | Editing style | Normalisation, diff, merge, filters, export |
With both, the problem of line endings between Ubuntu, macOS and Windows 11 — the GT-190 we resolved in lesson 08-04 — is attacked twice over: Carla's editor writes LF from the outset, and Git normalises anyway just in case.
- Review and analysis: status checks
Hosting platforms have a mechanism that unifies everything automatic: commit statuses (or checks). Any external system can publish, against a specific commit hash, a verdict: pending, success, failure.
flowchart TD
C["Commit a1b2c3d<br/>pushed to the proposal"] --> CI["Tests"]
C --> LINT["Linter"]
C --> COV["Coverage"]
C --> SEC["Security analysis"]
C --> STA["Static analysis"]
CI --> E["Statuses of commit a1b2c3d"]
LINT --> E
COV --> E
SEC --> E
STA --> E
E --> RP["Protected branch:<br/>all green?"]
RP -->|yes| OK["It can be integrated"]
RP -->|no| NO["Integration blocked"]
The conceptually important part: the status is associated with a commit hash, not with a branch or a proposal. It is consistent with the whole Git model (lesson 01-04): the commit is immutable, so a verdict on it remains valid forever. If you rewrite the branch, the new commits have different hashes and the checks run again, because they are different objects.
Hence a practical consequence that confuses a lot of people: after a rebase and a push --force-with-lease, all the checks run again. It is not a platform bug. It is that the commit is no longer the same one.
Which types of check deserve to be blocking
| Type | Blocking? | Reason |
|---|---|---|
| Tests | Yes | A failure is an objective failure |
| Build / packaging | Yes | If it does not build, there is nothing to review |
| Linter | Yes | It is deterministic and fixed in seconds |
| Secrets detected (08-05) | Yes | The cost of a false negative is enormous |
| Known vulnerabilities in dependencies | Depending on severity | Blocking on everything generates fatigue and ends up being ignored |
| Test coverage | With caveats | Blocking on an absolute threshold punishes whoever touches old code; better by variation |
| Heuristic static analysis | No | It produces false positives; let it inform, not block |
The rule: block on the deterministic, inform with the heuristic. A check that fails often for no real reason teaches the team to ignore the reds, and the day the red is genuine, nobody looks at it.
The detail you need to know about coverage
Measuring coverage over the whole project produces a metric that barely moves and that punishes arbitrarily. What is useful is measuring the coverage of the lines the proposal adds or modifies, and that is worked out, naturally, from a diff:
# Lines this branch touches relative to the divergence point
git diff --unified=0 origin/main...HEAD -- '*.js'That --unified=0 gives the diff without context: only the lines actually changed, with their numbers. It is what differential coverage tools consume. And once again the three dots (lesson 07-02), for the usual reason: we want what the branch contributes, not the difference with a main that has moved on.
format-patch, am and request-pull as a bridge between worlds
format-patch, am and request-pull as a bridge between worldsIn lesson 10-01 we saw these commands as the kernel's native workflow. Here they interest us for another reason: they are the universal exchange format between any tool and any repository.
A patch generated by format-patch is plain text that can be pasted into a ticket, attached to an email, saved in an archive or published on a web page. It needs no server, no account, no protocol. And git am applies it preserving authorship and message, something copying and pasting the code never does.
Real situations where this saves the day
1. Sending a change to somebody with no shared access. An external consultant, a colleague at another company, somebody on an isolated network.
# Source
git format-patch main --stdout > /tmp/GT-244.patch
# Destination: see what it would do before applying it
git apply --stat /tmp/GT-244.patch
git apply --check /tmp/GT-244.patch
# Apply it preserving authorship and message
git am /tmp/GT-244.patchgit apply --check is the dry run: it says whether the patch would apply cleanly without touching anything. And --stat shows which files it would affect. It is worth running both before applying anything that comes from outside.
2. Moving a commit between two repositories that know nothing about each other. When cherry-pick is no use because they share no history:
The -3 enables the three-way merge if the context does not match exactly. It is what turns a fragile patch into a reasonably robust one.
3. Archiving a change as readable text. A patch is self-describing and survives any platform migration. For changes that have to be kept for audit reasons, it is a better format than a link to a server that may not exist in ten years' time.
4. Reviewing without a platform. format-patch produces something that reads from top to bottom in any editor, with the commit message above the diff. For reviewing a series of commits on a plane, it beats any web interface.
git request-pull in a modern workflow
We already saw it in the previous lesson. Here just one specific use that is surprisingly handy: generating the text of a proposal.
It produces a summary with the base commit, the list of commits grouped by author and the diffstat. Pasted into the description of a large proposal, it saves the reviewer the work of figuring out the scope.
And as glue between the two worlds: a platform proposal can be turned into patches, because every proposal is, underneath, a set of commits on a branch.
That refs/pull/42/head is a refspec (lesson 04-01) that platforms expose for accessing proposal branches. It also works for reviewing them locally:
Reviewing a proposal by running it rather than merely reading it is, by a wide margin, the most effective way of reviewing. And it is one of the things the convenience of the web interface has made a lot of people stop doing.
- How to choose what to automate
We close with the criterion, which is what gives unity to everything above.
Automate when
- The task is repetitive and deterministic: formatting, sorting imports, checking the format of a message.
- The mistake is frequent and detectable: forgetting the ticket, committing a configuration file with credentials, leaving trailing whitespace.
- The check is objective: it passes or it does not, with no human judgement.
- The saving is for the whole team, not just for you.
Do not automate when
- The operation is irreversible and contextual: rewrites, force pushes, deletions.
- The decision requires judgement: whether a change is a good idea, whether the abstraction is the right one, whether the message explains the why well.
- The automation hides something you need to understand.
- The cost of a false positive is high: if it blocks the team's work for no reason, it will be switched off, and the good checks will go with it.
The recommended setup for a team like task-manager's
Recapping the whole lesson in a specific configuration:
| Layer | What is there | Cost |
|---|---|---|
| Editor | Differences in the gutter, inline blame, format on save, .editorconfig |
Initial configuration |
| Terminal | Prompt with branch and state, completion, Git aliases (06-04) | Half an hour, once |
| Client hooks (06-01) | prepare-commit-msg for the ticket; a light optional pre-commit |
Low, and optional |
| Repository | .gitignore, .gitattributes, .editorconfig, .git-blame-ignore-revs, CONTRIBUTING.md |
One day, once |
| CI (07-06) | Tests, linter, secrets, message format — blocking | Ongoing maintenance |
| Platform | Protected branch, proposal template, automatic ticket closing | Initial configuration |
Notice that most of the benefit is in the repository row: four text files that travel with the project and that benefit everybody without anybody having to configure their machine.
Common Mistakes and Tips
Mistake 1: using "Sync" without knowing what it does. It is the mistake of this lesson. Check git config --get pull.rebase and decide consciously. If the button does pull --rebase and you have published the branch, every sync may rewrite commits others already have (lesson 05-01).
Mistake 2: installing hooks nobody else has. Hooks are not distributed with the repository (lesson 06-01). If your team depends on a check, it has to be in CI or in a server hook. Local hooks are a personal convenience, never a guarantee.
Mistake 3: slow hooks. A pre-commit that takes fifteen seconds makes people commit less often and with bigger changes, or use --no-verify systematically. A client hook should take less than two seconds and check only the staged files.
Mistake 4: linting the disk instead of the index. If you have unstaged changes, you are validating something that is not what you are about to commit. Use git diff --cached --name-only --diff-filter=ACM and the stash --keep-index trick.
Mistake 5: trusting the editor's blame without .git-blame-ignore-revs. After a mass reformatting, blame will tell you the whole file was written by whoever ran the formatter. Ten minutes of configuration fix it forever and for the whole team.
Mistake 6: blocking integration with noisy checks. A heuristic analysis with false positives, set as blocking, makes the team learn to ignore the reds. Block on the deterministic, inform with the rest.
Mistake 7: reviewing only through the web interface. For large proposals, fetch the branch and run it. git fetch origin refs/pull/N/head:pr-N and git diff main...pr-N give a far better review than a browser does.
Tip 1: watch your tool's command log for a week. Almost all of them have a console where they show what they run. It is the fastest way of learning the correspondence between buttons and commands.
Tip 2: put Git status in your prompt today. Of everything in this lesson, it is what prevents the most mistakes per minute invested, above all the in-progress operation indicator (|REBASE, |MERGING).
Tip 3: put the shared configuration into the repository. .editorconfig, .gitattributes, .gitignore and .git-blame-ignore-revs are four text files that make the project behave the same for everybody without depending on each person's machine.
Tip 4: when something odd happens in the interface, go to the terminal. git status, git log --oneline --graph -20 and git reflog tell you where you really are. The interface shows an interpretation; the commands show the state.
Exercises
Exercise 1: translating buttons into commands
Carla uses her editor for everything. Describe what is happening in each situation and which command is underneath:
A. She presses "Sync changes" with three unpushed local commits. When it finishes, her three commits have different hashes from before.
B. She presses "Discard changes" on a file with two hours of uncommitted work. She asks whether she can recover it with git reflog.
C. She presses "Undo last commit" and her changes reappear as staged in the changes panel.
Exercise 2: a message hook that does not get in the way
Write a commit-msg hook for task-manager that:
- Rejects the commit if the subject does not follow Conventional Commits (
type(optional scope): description). - Rejects the commit if the subject exceeds 72 characters.
- Warns but does not reject if the message does not mention any
GT-NNN. - Does not get in the way on merges or on automatic
rebase/revertcommits.
Explain why point 3 warns instead of rejecting.
Exercise 3: reviewing a large proposal without the web interface
Diego has opened proposal number 57 on task-manager, with 14 commits and 800 lines changed. Ana wants to review it seriously: understand the changes, run the tests and leave well-founded comments.
Write the sequence of commands you would use, explaining what each step contributes. Include what you would do if Diego pushes a corrected version and you want to see only what has changed relative to what you already reviewed.
Solutions
Solution 1
A. "Sync" has done pull --rebase and then push.
The hashes change because the rebase (lesson 05-01) rebuilds each commit on the new base: the parent changes, and since a commit's hash includes its parent's (lesson 01-04), the whole hash changes.
What is underneath, according to her configuration:
Is it a problem? It depends on whether the branch was published:
- A local branch, never pushed: nothing happens, this is what you want.
- A published branch with somebody else working on it: she has just rewritten shared history. The subsequent
pusheither was rejected, or was forced by the tool, leaving everybody else with a divergence (lesson 09-03).
Recommendation: Carla should check that setting consciously. pull.rebase = true is a good default for personal branches, but with a button that triggers it without asking, it is worth knowing about.
B. "Discard changes" is git restore <file>, and NO, it cannot be recovered with the reflog.
This is the critical distinction of the whole of module 9:
| Recoverable? | Why | |
|---|---|---|
| A "lost" commit | Yes, with git reflog (09-04) |
It is an object in the database |
| Content that was staged | Almost always, with git fsck --lost-found (09-04) |
git add created a blob object |
| Changes never staged or committed | No | They never became an object |
Her two hours of work, if she never ran git add, do not exist as far as Git is concerned. All that is left is to look at the editor's own local change history, which many editors keep and which is the only safety net in this case.
The lesson: git add is not just "stage for committing", it is also "save a recoverable copy". It is the definitive argument for staging early and often.
C. "Undo last commit" was git reset --soft HEAD~1.
The commit disappears from the branch and its changes stay in the index (which is why they appear as staged). With --mixed (reset's default) they would appear as modified but not staged; with --hard they would have disappeared.
The editor using --soft is the right thing for "undo the commit but keep the work". And the original commit still exists: git reflog shows it and git reset --hard HEAD@{1} restores it (lesson 09-02).
Solution 2
#!/bin/sh
# .git/hooks/commit-msg
# Validates the commit message for task-manager.
FILE="$1"
# First non-empty line that is not a comment
SUBJECT=$(grep -v '^#' "$FILE" | sed '/^[[:space:]]*$/d' | head -1)
# --- Exclusions: do not get in the way of automatic things ---
case "$SUBJECT" in
"Merge "*|"Revert "*|"fixup!"*|"squash!"*|"amend!"*)
exit 0 ;;
esac
# Nor during a rebase or a merge in progress
GITDIR=$(git rev-parse --git-dir)
if [ -d "$GITDIR/rebase-merge" ] || [ -d "$GITDIR/rebase-apply" ] \
|| [ -f "$GITDIR/MERGE_HEAD" ]; then
exit 0
fi
ERROR=0
# --- Rule 1: Conventional Commits ---
PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9-]+\))?!?: .+'
if ! printf '%s' "$SUBJECT" | grep -qE "$PATTERN"; then
echo "ERROR: the subject does not follow Conventional Commits."
echo " Received: $SUBJECT"
echo " Format: type(scope): description"
echo " Types: feat fix docs style refactor perf test build ci chore"
ERROR=1
fi
# --- Rule 2: subject length ---
LENGTH=$(printf '%s' "$SUBJECT" | wc -c)
if [ "$LENGTH" -gt 72 ]; then
echo "ERROR: the subject is $LENGTH characters long (maximum 72)."
ERROR=1
fi
# --- Rule 3: ticket, warning only ---
if ! grep -qE 'GT-[0-9]+' "$FILE"; then
echo "WARNING: the message does not mention any GT-NNN ticket."
echo " If the change corresponds to one, add it with 'git commit --amend'."
fi
exit $ERRORWhy point 3 only warns:
-
Legitimately, there is not always a ticket. Fixing a typo in the
README.md, adjusting the CI configuration or a maintenancechoredo not always have a ticket, and forcing people to invent one degrades the ticketing system: it fills up with fake entries created to satisfy the hook. -
A hook that rejects too much gets switched off. As soon as somebody has to use
--no-verifyonce a week, they stop using the hook. And in switching it off they also lose rules 1 and 2, which genuinely are valuable. One over-strict rule weakens the good ones. -
Rules 1 and 2 are objective; rule 3 is contextual. The format of the subject admits no reasonable exception. The presence of a ticket does. It is the same distinction as in section 8: block on the deterministic, inform with the heuristic.
-
If the team decides the ticket is mandatory, that rule has to be on the server or in CI, not in a local hook. A local hook is no guarantee to anybody but you.
The exclusions at the start matter too: a hook that rejects messages generated by git merge or git revert turns normal operations into an ordeal, and it is the number one reason hooks end up in the bin.
Solution 3
# 1. Fetch the proposal branch without depending on the web interface
git fetch origin refs/pull/57/head:pr-57
# 2. General scope: what does it touch and how much?
git diff --stat main...pr-57The three dots are compulsory (lesson 07-02): they give what the branch contributes since it separated from main, not the difference with the current main. With two dots, any advance in main would appear as if Diego had undone it.
There is already a lot to learn here. Fourteen well-split, well-named commits are reviewed one at a time; fourteen commits called "fixes" and "wip" have to be reviewed as a single block, and it deserves a comment asking him to tidy them up with rebase -i (lesson 05-02).
# 4. Review commit by commit, with the message in front of the diff
git log --reverse -p main..pr-57
# 5. Actually run it: this is what the web interface cannot do
git switch pr-57
npm ci && npm test
npm start # and test the behaviour by hand
# 6. Specific checks
git diff main...pr-57 --stat -- '*.test.js' # are there tests?
git log main..pr-57 --format=%B | grep -c 'GT-' # do they reference the ticket?
git diff main...pr-57 | grep -nE '(key|token|password|secret)' # secretsStep 5 is what distinguishes a real review from a read-through. Running the branch detects things no diff shows: that the interface ends up misaligned, that there is an incomprehensible error message, that an operation takes three seconds.
To review the second version, after Diego redoes the branch:
# Save a reference to what I already reviewed, BEFORE fetching the new version
git branch pr-57-v1 pr-57
# Fetch the corrected version (forcing, because Diego has rewritten his branch)
git fetch origin +refs/pull/57/head:pr-57
# The key question: what has changed relative to what I already reviewed?
git range-diff main pr-57-v1 pr-57git range-diff (lesson 07-02) is exactly the tool for this: it compares two versions of the same series of commits and shows a diff of diffs. It pairs up the equivalent commits and marks each one:
=unchanged relative to the previous version.!the same commit but with modified content, and it shows what changed inside.</>commits removed or added in the new version.
In a proposal of 14 commits where Diego has only corrected two, range-diff shows you those two and saves you reviewing the other twelve again. It is the command that makes reviewing long series sustainable, and the reason the kernel model (lesson 10-01) can afford to reach version 5 of a series.
The + in +refs/pull/57/head:pr-57 is the forced refspec (lesson 04-01): it allows the local reference to be updated even when the advance is not a fast-forward, which is exactly what happens when the other side has rewritten its branch.
Conclusion
Git is an engine; everything else is bodywork. This lesson has gone over that bodywork with one constant criterion: use what it contributes, always knowing what is underneath.
- Editors and IDEs genuinely contribute on the visual side: differences in the gutter, conflict resolution with the common base in view (
merge.conflictStyle = zdiff3), inlineblameand staging hunks with the mouse. Every button has a command behind it, and the most dangerous one is "Sync", which can rewrite your history according to a setting you may not remember putting there. - In the terminal it is worth carrying on doing everything irreversible or conceptual: rewrites, divergences,
reflog,--force-with-lease,bisect, debugging and preciselogqueries. The rule: use the interface to look, the terminal to decide. - Graphical clients win clearly at drawing the graph and comparing arbitrary points in the history. Git brings its own (
gitk,git gui) and they are everywhere. - A well-equipped terminal — Git status in the prompt, completion and Git aliases (06-04) — is the improvement with the best effort-to-benefit ratio in the module. The
|REBASE 2/5indicator on its own heads off a whole category of trouble. - Ticketing systems close the
GT-NNNconvention we have used throughout the course: the branch, the commits and the proposal carry the identifier, integration closes the ticket, and the result is being able to answer in thirty seconds "what changed with GT-231?" and "is it in production?". - Quality tools hook in at three places — editor, client hook and CI — and the healthy combination is editor + CI: the first gives an immediate answer, the second guarantees compliance, because local hooks neither get distributed nor enforce anything.
.editorconfigsolves at the source what.gitattributessolves at the boundary. - Status checks are associated with a commit hash, not with a branch: that is why rewriting the branch launches them all again. Block on the deterministic, inform with the heuristic.
format-patch,amandrequest-pullare the universal exchange format: they move a change with its authorship and its message between repositories that know nothing about each other, with no server and no permissions. Andrefs/pull/N/headlets you bring any proposal down locally to review it by running it, which is the only kind of review that finds certain problems.
The underlying idea, which is the same one we opened with:
Automate the repetitive and deterministic. Always understand which command is underneath. And keep the terminal for the irreversible.
What is coming
There is a kind of file that breaks everything we have set up so far. The editor cannot show its differences, the interface cannot resolve its conflicts, the linter has nothing to say about it and git diff answers with one curt line: Binary files differ.
And that is not the worst of it. The worst is that Git stores every version in full, forever, and everybody who clones the repository downloads them all. A designer who updates an 80 MB file five times has just added 400 MB to everybody's history, and deleting it achieves nothing, because the objects are still there (lesson 08-06).
In task-manager that problem does not exist yet. But the team is about to take on graphical assets, demonstration videos and the design files for ui-components, and it is best to sort it out before the history is contaminated, not afterwards.
Lesson 10-03: Git LFS for Large Files closes what we left pending in lessons 06-05, 08-04 and 08-06: what exactly that filter=lfs diff=lfs merge=lfs -text that appeared in .gitattributes is, how a file gets replaced by a text pointer, and what you need to know — including the awkward limitations — before adopting it.
Mastering Git: From Beginner to Advanced
Module 1: Introduction to Git
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
