GitGroundworkGit
4 / 18 written chapters

G4 · Part 1 · Foundations

Setup and configuration

Ten minutes of setup per machine: who you are, how you log in, and the defaults Git should have shipped with.

12 min read3 diagrams9 sections
09On this page
  1. Where settings live
  2. The first five minutes
  3. Settings that should have been defaults
  4. Aliases
  5. Work and personal identities with includeIf
  6. Authentication: SSH or HTTPS
  7. .gitattributes: per-path rules that travel with the repo
  8. A global gitignore for your machine's junk
  9. Verify your setup
Fresher Mid Senior

Git works straight after install, but several of its defaults exist only for backwards compatibility. A few minutes of setup saves you from commits under the wrong email, a password prompt on every push, CRLF noise in every diff, and a git pull that makes merge commits you never asked for. Do it once per machine, keep it in your dotfiles, and forget about it.

Where settings live

Every setting is a section.key = value pair, and the same key can be set in several files. Git reads them in a fixed order, and the last value it reads wins. That is how a repository can override your personal defaults, and how a single command can override everything.

system --system /etc/gitconfig, every user global --global ~/.gitconfig or ~/.config/git/config local --local .git/config, this repository worktree --worktree config.worktree, one checkout command git -c key=value this one command only read in order; the last one wins
Most of your settings belong in global. Local is for one repository, such as a different email for an open-source project. Worktree config only exists once you turn on extensions.worktreeConfig, and matters when you use git worktree.

The system file's path depends on how Git was installed: a Homebrew Git on Apple silicon reads /opt/homebrew/etc/gitconfig, and Git for Windows keeps its own under the install folder. Global has two possible homes. If both exist, Git reads ~/.config/git/config first and then ~/.gitconfig. When a setting behaves oddly, stop guessing and ask Git where each value came from:

$ git config --list --show-origin --show-scope
system  file:/opt/homebrew/etc/gitconfig   credential.helper=osxkeychain
global  file:/Users/aisha/.gitconfig      user.name=Aisha Khan
global  file:/Users/aisha/.gitconfig      user.email=aisha@hey.com
global  file:/Users/aisha/.gitconfig-work user.email=aisha@corp.dev
local   file:.git/config                  core.bare=false
local   file:.git/config                  remote.origin.url=git@github.com:acme/api.git

$ git config --show-origin --get user.email
file:/Users/aisha/.gitconfig-work	aisha@corp.dev

The output is tab-separated; it is lined up here for reading. Notice user.email appears twice. The second value wins, and --show-origin shows it came from an included file, covered below. Git 2.46 added verb-style subcommands: git config list, git config get user.email and git config set user.email you@example.com do the same as the older flag forms, which still work. Keys are case-insensitive (Git prints them in lower case), and a value can be removed with git config unset --global key (or the older git config --global --unset key). Examples on this site use the older forms because they work on every version you are likely to meet.

The first five minutes

git config --global user.name "Aisha Khan"
git config --global user.email "aisha@hey.com"
git config --global init.defaultBranch main
git config --global core.editor "code --wait"

Identity. Name and email are copied into every commit you make. They are not a login; Git does not check them at all. GitHub and GitLab link a commit to your account by matching the email against the addresses on your profile, so use one you have added there (or the host's private noreply address if you do not want your real email public). Commits made with the wrong email stay that way. You can fix the last one with git commit --amend --reset-author --no-edit, but older ones need a history rewrite. Setting user.useConfigOnly true stops Git from guessing an identity from your machine's host name, so a missing email becomes an error instead of aisha@aishas-macbook.local.

Default branch. init.defaultBranch (Git 2.28 and later) names the first branch in every new repository. Without it, Git still creates master, and current versions print a long hint on every git init warning that the default will change to main in Git 3.0. Setting it once silences the hint and makes every machine agree.

Editor. Git opens an editor for commit messages, interactive rebases and some merges. A GUI editor must be told to wait until you close the file, or Git sees it exit at once and aborts with an empty message: code --wait, subl -w, zed --wait. Terminal editors need no flag: vim, nano. The GIT_EDITOR environment variable beats the setting, which beats VISUAL and then EDITOR.

Settings that should have been defaults

git config --global pull.rebase true
git config --global push.autoSetupRemote true
git config --global rerere.enabled true
git config --global fetch.prune true
git config --global merge.conflictStyle zdiff3
git config --global diff.algorithm histogram
git config --global rebase.autoStash true
git config --global rebase.updateRefs true
git config --global commit.verbose true
git config --global branch.sort -committerdate
git config --global help.autocorrect prompt
SettingWhat changes
pull.rebase truegit pull rebases your local commits onto the fetched ones instead of creating a "Merge branch 'main' of …" commit. Teams that prefer to decide each time use pull.ff only, which refuses anything but a fast-forward.
push.autoSetupRemote trueThe first git push on a new branch creates the remote branch and sets the upstream. No more --set-upstream origin error. Git 2.37 and later.
rerere.enabled true"Reuse recorded resolution". Git remembers how you resolved a conflict and replays it when the same conflict appears again, for example on every rebase of a long-lived branch.
fetch.prune trueDeletes origin/* branches that were deleted on the server, so your branch list stops growing forever.
merge.conflictStyle zdiff3Conflict markers also show the merge base, the original text both sides started from. This makes most conflicts obvious. Git 2.35 and later; use diff3 on older versions.
diff.algorithm histogramUsually more readable diffs when code is moved or blocks repeat.
rebase.autoStash trueStashes uncommitted changes before a rebase and restores them after, instead of refusing to start.
rebase.updateRefs trueWhen you rebase a stack of branches, the branches in the middle of the stack move too. Git 2.38 and later.
commit.verbose trueShows the staged diff under the message in the editor.
branch.sort -committerdategit branch lists the most recently used branches first.
help.autocorrect promptgit stauts asks "Run 'status' instead?" rather than just failing.

Aliases

An alias is a new Git subcommand made of an existing one plus arguments. Start with a handful you will really type:

git config --global alias.st "status -sb"
git config --global alias.lg "log --graph --format='%C(auto)%h%d %s %C(dim)%an, %ar'"
git config --global alias.last "log -1 HEAD --stat"
git config --global alias.unstage "restore --staged"
git config --global alias.amend "commit --amend --no-edit"
git config --global alias.gone "!git branch -vv | grep ': gone]' | awk '{print \$1}'"

An alias starting with ! runs as a shell command from the top of the repository, which is how gone above can use a pipe to list local branches whose remote branch has been deleted. Aliases cannot replace built-in commands: alias.status is silently ignored. Run git config --get-regexp '^alias\.' to list the ones you have.

Work and personal identities with includeIf

Many people have one laptop and two identities: a work email for company repositories and a personal one for everything else. Setting the email per repository works until you forget once. includeIf makes it automatic, based on where the repository lives on disk.

$ cat ~/.gitconfig
[user]
	name = Aisha Khan
	email = aisha@hey.com
[includeIf "gitdir:~/work/"]
	path = ~/.gitconfig-work

$ cat ~/.gitconfig-work
[user]
	email = aisha@corp.dev
[core]
	sshCommand = ssh -i ~/.ssh/id_ed25519_work

$ cd ~/work/api && git config user.email
aisha@corp.dev
$ cd ~/code/blog && git config user.email
aisha@hey.com
~/work/api matches gitdir:~/work/ ~/code/blog no match ~/.gitconfig [user] email = aisha@hey.com [includeIf "gitdir:~/work/"] path = ~/.gitconfig-work aisha@corp.dev from ~/.gitconfig-work aisha@hey.com from ~/.gitconfig
The include is read at the point where it appears. Because it comes after [user], its email is read later and wins. Put includeIf blocks at the end of the file.

The rules that catch people out:

  • The trailing slash matters. gitdir:~/work/ matches every repository at any depth under ~/work. Without the slash it matches only a repository whose .git is exactly that path.
  • Order matters. An include placed before your default [user] block is overridden by it.
  • It only applies inside a repository. Outside one, such as when you run git config user.email in your home folder, you see the default.
  • Other conditions exist. gitdir/i: is case-insensitive, onbranch:release/** matches the current branch, and hasconfig:remote.*.url:git@github.com:acme/** (Git 2.36 and later) matches on the remote URL, which works even when work repositories are not in one folder.

Authentication: SSH or HTTPS

The remote URL decides how you log in. git@github.com:acme/api.git uses SSH; https://github.com/acme/api.git uses HTTPS. Either is fine, and you can switch at any time with git remote set-url origin <url>. Note that GitHub has not accepted account passwords for Git over HTTPS since 2021. The "password" is a personal access token or an OAuth login.

SSH remote git@github.com:acme/api ssh + ssh-agent key: ~/.ssh/id_ed25519 GitHub matches your public key HTTPS remote https://github.com/acme/api credential helper token from the keychain GitHub checks the token
Git itself never stores a secret. SSH hands the job to ssh and its agent; HTTPS hands it to a credential helper. That is why fixing a login problem is usually outside Git's own settings.

SSH: one key per machine

$ ssh-keygen -t ed25519 -C "aisha@hey.com"
$ ssh-add ~/.ssh/id_ed25519
$ cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG4r... aisha@hey.com

$ ssh -T git@github.com
Hi aisha! You've successfully authenticated, but GitHub does not provide shell access.

Paste the .pub line into your account's SSH keys page; the private file never leaves your machine. Protect it with a passphrase and let ssh-agent (or the macOS keychain, via UseKeychain yes in ~/.ssh/config) remember it. If you need two GitHub accounts on one machine, give each its own key. Either use core.sshCommand in the work include file, as shown earlier, or define a host alias in ~/.ssh/config such as Host github-work with its own IdentityFile, and use git@github-work:acme/api.git as the remote.

HTTPS: let a credential helper hold the token

HelperWhereStores the secret in
osxkeychainmacOSThe login keychain. Often preset in the system config.
managerWindows, also macOS and LinuxGit Credential Manager: OS secure storage, with browser-based OAuth sign-in and 2FA support.
libsecretLinux desktopsGNOME Keyring or KWallet.
cache --timeout=3600AnywhereMemory only, forgotten after the timeout.
storeAnywhere~/.git-credentials as plain text. Avoid it.

Set one with git config --global credential.helper osxkeychain. If you use the GitHub CLI, gh auth login followed by gh auth setup-git configures a helper that reuses the CLI's login. When a token expires or is revoked, the helper keeps offering the stale one; remove it from the keychain (or run git credential reject) and the next push will ask again.

Sign commits with the key you already have

Since Git 2.34 you can sign commits with an SSH key instead of GPG: set gpg.format ssh, user.signingKey ~/.ssh/id_ed25519.pub and commit.gpgSign true. Add the same public key to your host as a signing key and commits show as verified. Signing proves who made a commit; the email field alone proves nothing.

For seniors: config that protects you

A repository's own .git/config can run programs: core.fsmonitor, core.sshCommand, hooks and filters are all commands. Since Git 2.35.2, Git refuses to read a repository owned by another user, with fatal: detected dubious ownership in repository, so a shared folder or a mounted volume cannot quietly run someone else's settings as you. In a CI container where the checkout belongs to a different user, trust exactly that path with git config --global --add safe.directory /work/app rather than '*'. Also note what config never travels: a clone copies no .git/config and no hooks, which is why team rules belong in committed files such as .gitattributes and on the server, not in anyone's local config. On shared or build machines, transfer.fsckObjects true makes fetch and receive check every incoming object and reject corrupt or malformed ones.

.gitattributes: per-path rules that travel with the repo

Your config is personal. .gitattributes is committed, so every clone gets the same behaviour. Each line is a path pattern followed by attributes.

* text=auto
*.sh  text eol=lf
*.bat text eol=crlf
*.png binary
*.pdf binary
*.jpg diff=exif
*.go  diff=golang
*.md  diff=markdown
package-lock.json -diff linguist-generated=true
dist/** linguist-generated=true
vendor/** linguist-vendored
docs/** linguist-documentation
CHANGELOG.md merge=union
/.github export-ignore
/tests export-ignore
.gitattributes export-ignore

Line endings. * text=auto tells Git to detect text files and store them with LF in the repository, converting on checkout if needed. eol=lf forces LF in the working directory too, which shell scripts need even on Windows, and eol=crlf does the reverse for batch files. This beats the per-user core.autocrlf setting because it does not depend on every contributor configuring their machine. After adding these rules to an existing project, run git add --renormalize . and commit, so files already stored with CRLF are fixed once, in one commit.

Binary and diff. binary is shorthand for -diff -merge -text: no line-ending conversion, no text diff, no attempt to merge. -diff alone keeps a huge generated file like a lock file out of your diffs ("Binary files differ") while still treating it as text. A diff= driver changes how diffs are shown. The built-in ones (golang, python, java, markdown and others) give better hunk headers, so a diff says which function it is in. A custom driver can convert a binary to text first: git config --global diff.exif.textconv exiftool makes photo diffs show changed metadata.

Linguist. The linguist-* attributes are read by GitHub, not Git. linguist-generated collapses the file in pull request diffs and leaves it out of the language statistics; linguist-vendored and linguist-documentation also drop paths from the statistics.

Merge and export. merge=union keeps lines from both sides instead of reporting a conflict. That suits an append-only file like a changelog, and is wrong for code. export-ignore leaves paths out of git archive, and so out of the source tarballs GitHub builds for releases.

$ git check-attr -a deploy.sh logo.png package-lock.json
deploy.sh: text: set
deploy.sh: eol: lf
logo.png: binary: set
logo.png: diff: unset
logo.png: merge: unset
logo.png: text: unset
package-lock.json: diff: unset
package-lock.json: text: auto
package-lock.json: linguist-generated: true

A global gitignore for your machine's junk

A project's .gitignore should list what the project produces: node_modules/, dist/, .env. Files produced by your operating system and editor belong in your own global ignore file, so nobody has to add .DS_Store to every repository on your behalf.

$ mkdir -p ~/.config/git
$ printf '.DS_Store\nThumbs.db\n.idea/\n*.swp\n.vscode/\n' > ~/.config/git/ignore
$ git check-ignore -v .DS_Store
/Users/aisha/.config/git/ignore:1:.DS_Store	.DS_Store

~/.config/git/ignore is read automatically. To use another file, point core.excludesFile at it. For patterns that apply to one clone only, such as a scratch folder you keep inside a project, use .git/info/exclude; it works like .gitignore but is never committed.

Verify your setup

Run these once on a new machine. Each one answers a question you would otherwise discover the hard way.

$ git --version
git version 2.53.0

$ git config user.name && git config user.email
Aisha Khan
aisha@hey.com

$ git config --show-origin --get init.defaultBranch
file:/Users/aisha/.gitconfig	main

$ git var GIT_EDITOR
code --wait

$ ssh -T git@github.com
Hi aisha! You've successfully authenticated, but GitHub does not provide shell access.

$ git ls-remote origin HEAD
1b06427a8c7d8fe438acd53c39008641a3cff92e	HEAD

Check the email inside a work folder and a personal folder if you use includeIf. git ls-remote is the cheapest proof that authentication works: it lists the remote's refs without changing anything. When something still fails, GIT_TRACE=1 git fetch shows what Git runs, and GIT_SSH_COMMAND="ssh -v" git fetch shows which SSH key is offered and why it is refused.

Keep it in your dotfiles

Put ~/.gitconfig, your include files and ~/.config/git/ignore in a dotfiles repository. A new laptop then gets your identity, aliases and defaults in one clone. Keep secrets out of it: tokens belong in the credential helper and keys in ~/.ssh, never in config.