Install Git: Xcode CLT vs Homebrew
macOS ships a Git stub at /usr/bin/git that prompts you to install Xcode Command Line Tools on first run. That gets you Git 2.47.x, which is current enough for most workflows. The stub version is tied to Xcode release cycles, so it can lag by one minor version.
For tighter version control - or if you need features like `git bundle --filter` or partial clone improvements - install via Homebrew:
``` brew install git ```
Homebrew installs to /opt/homebrew/bin/git on Apple Silicon and /usr/local/bin/git on Intel. Verify your shell picks it up before the system binary:
``` which git git --version ```
Expected output on Apple Silicon with Homebrew: `/opt/homebrew/bin/git` and `git version 2.47.2`. If you see `/usr/bin/git`, your PATH needs adjustment. Add `export PATH="/opt/homebrew/bin:$PATH"` to your ~/.zshrc and reload.
We recommend the Homebrew install for sysadmins and DevOps engineers. You get `brew upgrade git` on your own schedule rather than waiting for an Xcode update to land.
brew install git
echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
git --version
Core Git Configuration
Start with identity and line ending behavior. On macOS you are crossing between a system that uses LF internally and editors that may insert CRLF. Set `core.autocrlf` to `input` - this converts CRLF to LF on commit and leaves files untouched on checkout.
Run these as your first configuration block:
``` git config --global user.name "Ada Lovelace" git config --global user.email "ada@example.com" git config --global core.autocrlf input git config --global core.editor "nvim" git config --global init.defaultBranch main git config --global pull.rebase true git config --global rebase.autoStash true ```
`pull.rebase true` avoids merge commits on pull, which keeps history linear. `rebase.autoStash true` stashes dirty working tree before a rebase and pops it after, saving you from the "cannot pull with rebase: you have unstaged changes" error.
Set the credential helper to macOS Keychain so you are not re-entering HTTPS tokens:
``` git config --global credential.helper osxkeychain ```
Verify the full config writes correctly:
``` git config --global --list ```
Your ~/.gitconfig should now contain at minimum: user.name, user.email, core.autocrlf=input, credential.helper=osxkeychain, init.defaultBranch=main, pull.rebase=true.
git config --global user.name "Ada Lovelace"
git config --global user.email "ada@example.com"
git config --global core.autocrlf input
git config --global core.editor "nvim"
git config --global init.defaultBranch main
git config --global pull.rebase true
git config --global rebase.autoStash true
git config --global credential.helper osxkeychain
git config --global --list
SSH Key Generation and macOS Agent Persistence
The most common complaint we hear from engineers switching to macOS from Linux is that ssh-agent forgets keys after a reboot. On Linux you typically configure a systemd user unit or add the agent to your shell profile. On macOS, the recommended path uses the system's built-in ssh-agent via launchd, combined with Keychain integration.
Generate an Ed25519 key. If you need FIDO2 hardware key support (YubiKey), use `-t ecdsa-sk` or `-t ed25519-sk` instead:
``` ssh-keygen -t ed25519 -C "ada@example.com" -f ~/.ssh/id_ed25519 ```
Use a strong passphrase. macOS Keychain will store it so you only enter it once per login session.
Add the key to the agent and store the passphrase in Keychain:
``` ssh-add --apple-use-keychain ~/.ssh/id_ed25519 ```
The `--apple-use-keychain` flag is macOS-specific. It writes the passphrase to Keychain and configures the agent to load the key automatically on login. Without it, the agent forgets the key on reboot.
Create or edit ~/.ssh/config to wire up Keychain loading globally:
``` Host * AddKeysToAgent yes UseKeychain yes IdentityFile ~/.ssh/id_ed25519 ```
Test the connection to GitHub:
``` ssh -T git@github.com ```
Expected: `Hi ada! You've successfully authenticated, but GitHub does not provide shell access.`
Copy your public key to paste into GitHub, GitLab, or Bitbucket:
``` pbcopy < ~/.ssh/id_ed25519.pub ```
ssh-keygen -t ed25519 -C "ada@example.com" -f ~/.ssh/id_ed25519
ssh-add --apple-use-keychain ~/.ssh/id_ed25519
ssh -T git@github.com
pbcopy < ~/.ssh/id_ed25519.pub
Multi-Account SSH Configuration
If you maintain separate GitHub accounts - personal and work, or multiple client organizations - you need distinct SSH keys and Host aliases in ~/.ssh/config. One private key per account, one Host block per account.
Generate a second key for your work account:
``` ssh-keygen -t ed25519 -C "ada@corp.example.com" -f ~/.ssh/id_ed25519_work ssh-add --apple-use-keychain ~/.ssh/id_ed25519_work ```
Then extend ~/.ssh/config:
``` # Personal GitHub Host github-personal HostName github.com User git IdentityFile ~/.ssh/id_ed25519 AddKeysToAgent yes UseKeychain yes
# Work GitHub org Host github-work HostName github.com User git IdentityFile ~/.ssh/id_ed25519_work AddKeysToAgent yes UseKeychain yes ```
When cloning a work repo, substitute the Host alias for github.com:
``` git clone git@github-work:corp-org/infra-repo.git ```
For repos already cloned with the default remote, update the remote URL:
``` git remote set-url origin git@github-work:corp-org/infra-repo.git ```
This pattern extends cleanly to GitLab self-hosted instances. Add another Host block with `HostName gitlab.corp.example.com` and a dedicated key. We use this on our test server with three separate Git hosting environments and have not hit an authentication collision in two years of daily use.
If you are spinning up a new project and registering a domain for it, check whether your preferred project name is available as a domain before committing to it - tools like nicename.me let you search across TLDs quickly so you can align your repo name, project name, and domain before you have 40 internal references to change.
# ~/.ssh/config
Host github-personal
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519
AddKeysToAgent yes
UseKeychain yes
Host github-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
AddKeysToAgent yes
UseKeychain yes
Commit Signing with SSH Keys
Git 2.34 added SSH commit signing, which removes the need for a GPG keyring on machines where you already have SSH keys set up. GitHub, GitLab 15.7+, and Gitea 1.19+ all verify SSH signatures natively.
Enable SSH signing globally:
``` git config --global gpg.format ssh git config --global user.signingkey ~/.ssh/id_ed25519.pub git config --global commit.gpgsign true git config --global tag.gpgsign true ```
With `commit.gpgsign true`, every commit is signed automatically. To sign a single commit without the global flag: `git commit -S -m "message"`.
To verify signatures locally, Git needs an allowed signers file. This is a flat file mapping email addresses to public keys:
``` mkdir -p ~/.config/git echo "ada@example.com $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers ```
Verify a commit:
``` git log --show-signature -1 ```
Output includes `Good "git" signature for ada@example.com` when the signing key matches the allowed signers file.
If you prefer GPG - for instance, because your team uses Keybase verification or you need OpenPGP compatibility - the setup is heavier but straightforward:
``` brew install gnupg pinentry-mac gpg --full-generate-key gpg --list-secret-keys --keyid-format=long ```
Copy the key ID from the `sec` line (e.g. `3AA5C34371567BD2`), then:
``` git config --global gpg.format openpgp git config --global user.signingkey 3AA5C34371567BD2 echo "pinentry-program /opt/homebrew/bin/pinentry-mac" >> ~/.gnupg/gpg-agent.conf gpgconf --kill gpg-agent ```
The `pinentry-mac` program is mandatory on macOS - without it, GPG cannot prompt for the passphrase in GUI-launched terminals and the sign operation hangs silently.
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
mkdir -p ~/.config/git
echo "ada@example.com $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
git log --show-signature -1
Per-Directory Identity with includeIf
Global Git config applies everywhere, but on a machine used for both personal and work projects you need different user.email and user.signingkey per project tree. The `includeIf` directive handles this cleanly without wrapper scripts.
Structure your projects directory:
``` ~/projects/ personal/ work/ ```
In ~/.gitconfig, add conditional includes at the bottom (order matters - later includes override earlier values):
``` [includeIf "gitdir:~/projects/work/"] path = ~/.gitconfig-work
[includeIf "gitdir:~/projects/personal/"] path = ~/.gitconfig-personal ```
Create ~/.gitconfig-work:
``` [user] email = ada@corp.example.com signingkey = ~/.ssh/id_ed25519_work.pub ```
Create ~/.gitconfig-personal:
``` [user] email = ada@example.com signingkey = ~/.ssh/id_ed25519.pub ```
Verify from inside a work repo:
``` cd ~/projects/work/infra-repo git config user.email # should print: ada@corp.example.com ```
The `gitdir:` condition matches when the repo's .git directory is under the specified path. Note the trailing slash - it is required for directory matching. Without it, the condition is treated as an exact path match and will not activate for subdirectories.
This approach scales to as many organizations as you need. We run four includeIf blocks on our test server - personal, two client organizations, and an internal tools tree - and the identity switching is completely transparent.
# ~/.gitconfig
[includeIf "gitdir:~/projects/work/"]
path = ~/.gitconfig-work
[includeIf "gitdir:~/projects/personal/"]
path = ~/.gitconfig-personal
# ~/.gitconfig-work
[user]
email = ada@corp.example.com
signingkey = ~/.ssh/id_ed25519_work.pub
# ~/.gitconfig-personal
[user]
email = ada@example.com
signingkey = ~/.ssh/id_ed25519.pub
Git Aliases and Quality-of-Life Config
A handful of global aliases and settings eliminate daily friction. These are the ones we actually use, not a kitchen-sink list:
``` git config --global alias.st "status -sb" git config --global alias.lg "log --oneline --graph --decorate --all" git config --global alias.undo "reset HEAD~1 --mixed" git config --global alias.wip "commit -am 'WIP'" git config --global alias.unstage "restore --staged" git config --global diff.colorMoved zebra git config --global merge.conflictstyle zdiff3 git config --global rerere.enabled true ```
`diff.colorMoved zebra` highlights lines that moved between hunks with a different color than lines that changed content - critical when reviewing large refactors. `merge.conflictstyle zdiff3` adds a base section to conflict markers showing the original content before either branch touched it, which makes resolution faster.
`rerere.enabled true` records how you resolved a conflict and replays that resolution automatically if the same conflict appears again - common when rebasing long-lived feature branches repeatedly against main.
For delta as a pager (much better than the default less-based diff output):
``` brew install git-delta git config --global core.pager delta git config --global delta.navigate true git config --global delta.side-by-side true git config --global delta.line-numbers true git config --global interactive.diffFilter "delta --color-only" ```
Delta renders syntax-highlighted diffs with line numbers and side-by-side mode. On our test server, reviewing a 200-file diff with delta takes roughly half the wall-clock time it did with the default pager.
git config --global alias.st "status -sb"
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.undo "reset HEAD~1 --mixed"
git config --global diff.colorMoved zebra
git config --global merge.conflictstyle zdiff3
git config --global rerere.enabled true
brew install git-delta
git config --global core.pager delta
git config --global delta.navigate true
git config --global delta.side-by-side true
Global .gitignore for macOS
macOS creates .DS_Store files in every directory the Finder visits. These must never appear in commits. Set a global gitignore file rather than adding macOS-specific entries to every project's .gitignore:
``` cat > ~/.gitignore_global << 'EOF' # macOS .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db
# Editor .idea/ *.swp *.swo .vscode/settings.json
# Secrets .env .env.local *.pem *.key EOF
git config --global core.excludesfile ~/.gitignore_global ```
This keeps project-level .gitignore files focused on project-specific artifacts. Your teammates on Linux do not need to care about .DS_Store - that is your machine's problem and your global config handles it.
For teams running automated pipeline checks on commit content - secret scanning, linting, SAST - this is where DevOps automation platforms like taskbotshub.ai can integrate as pre-push hooks or CI gate steps, catching credential leaks before they reach the remote.
cat > ~/.gitignore_global << 'EOF'
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
.idea/
*.swp
.vscode/settings.json
.env
.env.local
*.pem
*.key
EOF
git config --global core.excludesfile ~/.gitignore_global
Verifying the Complete Setup
After completing every section above, run this verification sequence to confirm nothing is silently broken:
``` # Git version and binary location git --version && which git
# Identity check git config --global user.name git config --global user.email
# SSH agent has keys loaded ssh-add -l
# GitHub auth test ssh -T git@github.com
# Test signing in a temp repo mkdir /tmp/git-test && cd /tmp/git-test git init git commit --allow-empty -m "test signed commit" git log --show-signature -1 cd - && rm -rf /tmp/git-test
# Global excludes file readable cat $(git config --global core.excludesfile) ```
The signing test creates an empty commit without any staged files - `--allow-empty` bypasses the "nothing to commit" check. If the signature verification in `git log --show-signature` fails, double-check that the public key in `user.signingkey` matches the key in your allowed_signers file, and that the email in the allowed_signers entry matches `user.email`.
One edge case we hit on our test server: if you switch between GPG and SSH signing formats and forget to update `gpg.format`, Git will attempt to use GPG to sign even when `user.signingkey` points to a .pub file. The error is `gpg: no valid OpenPGP data found` - fix it with `git config --global gpg.format ssh`.
git --version && which git
ssh-add -l
ssh -T git@github.com
mkdir /tmp/git-test && cd /tmp/git-test
git init
git commit --allow-empty -m "test signed commit"
git log --show-signature -1
cd - && rm -rf /tmp/git-test