Install and Configure Git Correctly Before Touching a Repo
On RHEL 9 and clones, the default `git` from BaseOS is 2.43. On Debian 12 (Bookworm), it is 2.39. Neither is current. Build from source or pull from a maintained third-party repo if you need the latest.
For RHEL/AlmaLinux, the IUS or Remi repos carry current Git builds. On Debian/Ubuntu, the `git-core` PPA from `ppa:git-core/ppa` tracks upstream closely. After installing, confirm with `git --version`.
Global config lives in `~/.gitconfig`. Set these three values before doing anything else. Without `user.email` and `user.name`, every commit on a shared server will be attributed to `root@hostname` or whatever the system default resolves to, and untangling that later is painful.
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global core.editor vim
git config --global init.defaultBranch main
git config --global pull.rebase true
git config --global fetch.prune true
Initialize a Repository and Understand the Object Store
Every Git repo is a content-addressed object store under `.git/objects/`. When you run `git init`, Git creates that directory along with `HEAD`, `config`, `description`, and the `refs/` tree. Nothing is magical - it is a filesystem structure you can read directly.
Run `git init myproject` and then `find myproject/.git -type f` to see the initial state. There are no objects yet. Add a file and commit, then run `find .git/objects -type f`. You will see three object files: a blob (the file content), a tree (the directory listing), and a commit (pointing at the tree plus metadata).
Object hashes are SHA-1 in Git versions before 2.29, and SHA-256 is available as an experimental backend since then. To see what any object contains, use `git cat-file -p
git init myproject
cd myproject
echo 'hello' > README.md
git add README.md
git commit -m 'Initial commit'
# Inspect the commit object
git cat-file -p HEAD
# Inspect the tree it points to
git cat-file -p HEAD^{tree}
# Inspect the blob inside that tree
git ls-tree HEAD
Staging, Committing, and What git add Actually Does
`git add` copies file content into the object store and updates the index (`.git/index`). The index is a binary file that tracks what will go into the next commit. This is why you can `git add` a file, then modify it again, then `git add` again - the first snapshot is already stored as a blob object, and the second `git add` replaces the index entry.
Use `git add -p` (patch mode) to stage individual hunks rather than whole files. This is the correct way to make atomic commits when you have been working on multiple things simultaneously. It drops you into an interactive prompt where `y` stages the hunk, `n` skips it, and `s` splits it into smaller pieces if the hunk contains logically separate changes.
`git diff` shows unstaged changes. `git diff --staged` shows what is staged. Both output standard unified diff format. If you pipe to a pager that strips color, add `--color=always`.
For commit messages, the 50/72 rule is enforced by many code review tools: subject line under 50 characters, body wrapped at 72. Set `commit.template` in your global config to automatically load a template file with that structure.
# Stage interactively
git add -p
# Review what you are about to commit
git diff --staged
# Commit with a multi-line message from the editor
git commit
# Amend the last commit (before pushing)
git commit --amend --no-edit
Branching and Merging Without Fear
A branch in Git is a 41-byte file in `.git/refs/heads/` containing a commit hash. Creating a branch is instantaneous regardless of repo size. `git branch feature/auth` writes that file. `git checkout -b feature/auth` creates it and updates `HEAD` to point to it. The modern equivalent is `git switch -c feature/auth`, introduced in 2.23.
When you merge, Git has three strategies it chooses between automatically: fast-forward (linear history, no merge commit), recursive (the default for diverged branches, creates a merge commit), and octopus (multiple branches at once). For server deployments we prefer explicit merge commits on main - use `git merge --no-ff feature/auth` to force one even when fast-forward is possible. The merge commit documents when and what was integrated.
Merge conflicts drop conflict markers into the affected files. Run `git status` to list them. Edit the files to resolve, then `git add
`git log --graph --oneline --all` gives you the DAG view of all branches. On a server with many long-running feature branches, this is the fastest way to understand the current state without opening a web UI.
git switch -c feature/auth
# do work
git add -p
git commit -m 'Add JWT middleware'
git switch main
git merge --no-ff feature/auth
# View the result
git log --graph --oneline --all -20
Rebasing: When to Use It and When to Avoid It
Rebase rewrites commit history by replaying commits onto a new base. `git rebase main` while on a feature branch takes every commit on that branch since it diverged and replays them on top of the current tip of main. The result is a linear history with new SHA hashes for every replayed commit.
The rule: never rebase commits that have been pushed to a shared remote. If someone else has pulled those commits, your rebased history will diverge from theirs and they will need to force-reset or re-merge. On a personal feature branch that only you are working on, rebase freely before opening a pull request.
Interactive rebase is one of the most powerful tools in Git. `git rebase -i HEAD~5` opens your editor with the last five commits listed. You can reorder them, squash multiple commits into one (`squash` or `s`), edit commit messages (`reword` or `r`), or split a commit (`edit` then amend + new commits). This is how you clean up a messy working branch before merging to main.
If a rebase goes wrong mid-way, `git rebase --abort` returns you to the original state. If you need to recover from a bad completed rebase, `git reflog` shows the full history of where HEAD has pointed, including the pre-rebase state. `git reset --hard HEAD@{4}` type syntax lets you jump back.
# Rebase feature branch onto current main
git fetch origin
git rebase origin/main
# Interactive rebase - clean up last 5 commits
git rebase -i HEAD~5
# If something breaks
git rebase --abort
# Find the pre-rebase state
git reflog | head -20
Remote Operations: fetch, pull, push, and the Difference
`git fetch` downloads objects and refs from the remote without touching your working tree or current branch. It is always safe. `git pull` is `git fetch` followed by either `git merge` or `git rebase` depending on your `pull.rebase` config. We set `pull.rebase true` in the config section above because merge commits from pulls clutter history.
Push with `git push origin feature/auth`. The first push to a new branch requires `-u` to set the upstream tracking reference: `git push -u origin feature/auth`. After that, `git push` with no arguments works from that branch.
Force pushing is sometimes necessary after a rebase. Use `--force-with-lease` instead of `--force`. The difference: `--force-with-lease` checks that the remote ref matches what you last fetched before overwriting. If someone else pushed to that branch between your fetch and your push, `--force-with-lease` will fail instead of silently destroying their work.
To delete a remote branch: `git push origin --delete feature/auth`. To prune all stale remote-tracking refs locally: `git remote prune origin`, or set `fetch.prune true` as we did above to do it automatically on every fetch.
# Set upstream on first push
git push -u origin feature/auth
# Force push after rebase - safely
git push --force-with-lease origin feature/auth
# Delete remote branch
git push origin --delete feature/auth
# See tracking relationships
git branch -vv
Git Hooks for Automated Enforcement
Hooks are scripts in `.git/hooks/` that Git executes at specific points in the workflow. They are not committed to the repository by default, which makes distributing them slightly awkward. The standard solution is to keep hooks in a `hooks/` directory in the repo root and symlink or copy them during onboarding, or use a tool like `pre-commit` to manage installation.
The most useful hooks for server-side automation are `pre-receive` and `post-receive` on the remote. `pre-receive` runs before any refs are updated and can reject a push entirely. `post-receive` runs after the push completes and is the right place to trigger deployments or notifications.
Client-side hooks run locally. `pre-commit` fires before the commit message is entered and is ideal for running linters, tests, or secret scanners. Exit non-zero to abort the commit. `commit-msg` receives the commit message file path as an argument and can enforce message format. The following `commit-msg` hook rejects commits that do not start with a ticket number:
For teams running self-hosted CI/CD, the `post-receive` hook on a bare repository is the simplest possible deployment pipeline - no external dependencies. If you are moving toward more structured automation, platforms like taskbotshub.ai provide AI-assisted pipeline generation that can ingest your existing hook scripts and produce GitOps-compatible workflows.
#!/bin/bash
# .git/hooks/commit-msg
# Enforce ticket prefix: PROJ-1234 or hotfix/
MSG=$(cat "$1")
if ! echo "$MSG" | grep -qE '^(PROJ-[0-9]+|hotfix/)'; then
echo "ERROR: Commit message must start with PROJ-NNNN or hotfix/"
exit 1
fi
# Make executable
chmod +x .git/hooks/commit-msg
Working with Tags and Releases
Git has two types of tags: lightweight and annotated. Lightweight tags are just a named pointer to a commit, identical in structure to a branch. Annotated tags are full objects with a tagger name, email, date, and message. Use annotated tags for releases.
`git tag -a v2.1.0 -m 'Release 2.1.0'` creates an annotated tag on HEAD. `git tag -a v2.1.0 abc1234 -m 'Release 2.1.0'` tags a specific commit. Push tags explicitly: `git push origin v2.1.0` or `git push origin --tags` to push all local tags.
To list tags matching a pattern: `git tag -l 'v2.*'`. To see the full tag object: `git cat-file -p v2.1.0`. To check what commit a tag points to: `git rev-parse v2.1.0^{}`.
For semantic versioning workflows, `git describe` is useful in build scripts. It outputs something like `v2.1.0-14-gabcdef7` - the nearest tag, commits since that tag, and the current short hash. Drop that string directly into a `VERSION` file during your build step.
# Create and push an annotated release tag
git tag -a v2.1.0 -m 'Release 2.1.0 - JWT auth support'
git push origin v2.1.0
# Use in a build script
VERSION=$(git describe --tags --always --dirty)
echo "Building version: $VERSION"
# List release tags
git tag -l 'v*' --sort=-version:refname | head -10
Stash, Worktrees, and Context Switching Without Losing Work
`git stash` saves your working tree and index changes to a stack and reverts to a clean state. `git stash pop` applies the most recent stash and removes it from the stack. `git stash apply stash@{2}` applies a specific stash without removing it. Always give stashes names: `git stash push -m 'half-done auth refactor'` makes the stack readable when you accumulate several.
For more complex context switching, `git worktree` is the better tool. It lets you check out multiple branches simultaneously into separate directories, all sharing the same `.git` object store. This is useful when you need to hotfix main while your feature branch is in a broken state.
`git worktree add ../hotfix-work main` creates a new working tree in `../hotfix-work` checked out to main. Work there, commit, push, then `git worktree remove ../hotfix-work` when done. The overhead is minimal - no object duplication. We use this regularly on CI servers to run parallel test jobs against different branches without cloning the full repository multiple times.
# Stash with a message
git stash push -m 'WIP: auth middleware'
# List stashes
git stash list
# Create a worktree for a hotfix
git worktree add ../hotfix-1234 main
cd ../hotfix-1234
git switch -c hotfix/CVE-2026-1234
# do work, commit, push
cd -
git worktree remove ../hotfix-1234
Searching History and Diagnosing Regressions
`git log` accepts a large number of filters that make it a genuine investigation tool. `git log --author='jsmith' --since='2 weeks ago' --grep='CVE' --oneline` finds all commits by jsmith in the last two weeks mentioning CVE. `git log -S 'password_hash'` (pickaxe search) finds every commit that added or removed the string `password_hash` - essential for auditing credential handling.
`git blame -L 40,60 src/auth.c` shows who last modified lines 40 through 60 of that file, with commit hash and date. Combine with `git show
For bisecting regressions, `git bisect` performs a binary search through commit history. You mark a known good commit and a known bad commit, and Git checks out the midpoint for you to test. Mark it good or bad, repeat until the first bad commit is identified. On large histories this finds the culprit in roughly log2(N) steps.
# Find who introduced a specific string
git log -S 'eval(' --oneline src/
# Blame specific lines
git blame -L 40,60 src/auth.c
# Bisect a regression
git bisect start
git bisect bad HEAD
git bisect good v2.0.0
# Git checks out midpoint - run your test
# then:
git bisect good # or: git bisect bad
# Repeat until:
# 'abc1234 is the first bad commit'
git bisect reset
Bare Repositories, Naming, and Self-Hosted Remotes
A bare repository has no working tree. It stores only the `.git` contents directly in the root directory. Bare repos are what you push to and pull from - GitHub, GitLab, and every Git server uses them. Create one with `git init --bare myproject.git`.
To host your own Git remote over SSH, all you need is a bare repo on a server you can SSH into and a user account. `git remote add origin ssh://git@yourserver.example.com/repos/myproject.git` is the full syntax. Restrict that user to Git operations only by setting their shell to `git-shell`.
When naming repositories, consistency matters at scale. A naming scheme like `
Set a repository description in the bare repo's `description` file - it shows up in `gitweb` and other web frontends. Set `receive.denyNonFastForwards true` in the bare repo's `config` to prevent force pushes to any branch.
# Create a bare repo
git init --bare /srv/git/myproject.git
# Restrict the git user to git-shell
chsh -s /usr/bin/git-shell git
# Configure the bare remote
cd /srv/git/myproject.git
git config receive.denyNonFastForwards true
git config receive.denyDeleteCurrent true
echo 'My project description' > description
# On client
git remote add origin git@yourserver:/srv/git/myproject.git
git push -u origin main
Performance Tuning for Large Repositories
On repositories with millions of objects, several Git settings materially affect performance. The commit-graph file (`git commit-graph write --reachable`) precomputes reachability data and speeds up `git log`, `git merge-base`, and `git branch --contains` significantly. Run it as a cron job or in a `post-receive` hook on the server.
`git gc` runs housekeeping: it packs loose objects, removes unreachable objects older than two weeks, and updates the commit-graph. `git gc --aggressive` does a more thorough repack but takes longer and is not needed routinely - once a month on active repos is sufficient. `git maintenance start` (added in 2.29) registers a systemd timer or launchd job to run incremental maintenance automatically.
Partial clone (`git clone --filter=blob:none
For the index on repos with tens of thousands of tracked files, enable the filesystem monitor: `git config core.fsmonitor true` on Linux with `inotifywait` or the bundled cross-platform daemon (`git fsmonitor--daemon`). On a repo with 50k files, this reduces `git status` time from roughly 800ms to under 50ms in our testing.
# Build commit-graph after every push (bare repo post-receive hook)
git commit-graph write --reachable --changed-paths
# Enable automatic maintenance
git maintenance start
# Shallow clone for CI
git clone --depth 1 --single-branch --branch main git@server:/repo.git
# Enable fsmonitor
git config core.fsmonitor true
git config core.untrackedCache true