LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Version Control Mastery: Git, GitHub, AI Agents, and the Home Lab Stack

gitgithubversion-controlsvnmercurialjujutsugiteaforgejowoodpecker-citailscalehomelabclaude-codegithub-copilotai-codingci-cdgitopsdevopsproductivitytrunk-based-developmentgithub-flowgitflow
Contents

Version control is the one non-negotiable piece of infrastructure in every software project, regardless of size. A solo developer with one machine and no deadline still benefits from version control. A team of fifty engineers shipping to production multiple times per day cannot function without it. Yet despite how long Git has been the dominant tool — twenty-plus years — most developers use perhaps thirty percent of its capability, ignore most of what makes it powerful, and have almost certainly lost work they didn’t need to lose.

This post is a comprehensive reference for developers who want to close that gap. It covers the full landscape of version control systems available in 2026, Git’s internal model and the advanced operations that follow logically from understanding it, the branching workflows that teams actually use, GitHub’s platform features, the growing integration of AI agents into version control workflows, and how to build a productive self-hosted Git stack in a home lab.


Part 1: The Version Control Landscape

A Brief History

Version control is not new. The first recognizable system, SCCS (Source Code Control System), dates to 1972. RCS followed in 1982, adding revision trees. CVS (Concurrent Versions System) emerged in 1990 and introduced networked collaboration. SVN (Subversion) arrived in 2000 as a clean rewrite of CVS that fixed the most glaring problems while keeping the centralized model. Then, in 2005, two things happened in the same year that changed everything: Linus Torvalds released Git, and Matt Mackall released Mercurial. Both were responses to the revocation of BitKeeper’s free license for Linux kernel development. Both were distributed. Both were fast. One won.

Git — The Undisputed Standard

Git is a distributed version control system where every clone contains the complete history of the repository. There is no inherent difference between the “origin” server and a developer’s local clone — both are full repositories. “Origin” is a convention, not a technical distinction.

What made Git win over Mercurial was not simplicity — Git is notoriously un-simple — but raw speed, the branching model, and gravitational pull from GitHub (launched 2008, GitHub grew into the de facto host for open-source software). The network effect became insurmountable. By the early 2010s, Mercurial had lost and Git had won.

Today, over 95% of professional developers use Git as their primary VCS.

SVN — Centralized and Still Alive

Apache Subversion (SVN) is a centralized system: there is one canonical server, and developers check out working copies. SVN never went away because it solves real problems that Git handles awkwardly:

  • Granular access control. SVN can restrict access at the directory level within a repository — specific paths can be read-only for specific users. Git has no equivalent; it works at the repository level. For enterprises with compliance requirements around code access, this matters.
  • Binary file handling. SVN stores binary files more efficiently using binary diffs. Git stores objects as content-addressed blobs without binary-specific optimization, making large binary repositories bloated.
  • Immutable history. SVN does not allow rewriting history. Every commit is permanent. For regulated industries where an audit trail must be tamper-evident, this is a feature not a limitation.
  • Familiar mental model. The centralized model is cognitively simpler. There is no index, no distributed state to reconcile, no force-push incidents.

SVN is not growing, but in 2026 it still runs code at major enterprises, game studios (large binary assets), and government agencies. If you encounter it, the core operations are svn checkout, svn update, svn commit, svn diff, and svn log — considerably fewer concepts than Git.

Mercurial — The Road Not Taken

Mercurial is a distributed VCS with a cleaner command surface than Git. Where Git grew organically with commands that reflect its internals, Mercurial was designed with user experience in mind from the start.

Key differences:

  • No staging area. You commit what’s on disk, no git add ceremony.
  • No rebasing in core. Mercurial’s philosophy is that history should not be rewritten. The mq (mercurial queues) and evolve extensions add non-destructive history editing.
  • Simpler branching model. Named branches are tracked as metadata on commits, not as mutable pointers. This is conceptually cleaner but less flexible.
  • Excellent at scale. Facebook, Mozilla, and Google used or still use Mercurial for their enormous monorepos. Facebook’s Eden project is a Mercurial fork rewritten in Rust, designed to handle billions of files.

Mercurial lost the community adoption battle but the codebase that runs much of the internet (Facebook, Instagram) still speaks Mercurial under the hood.

Jujutsu (jj) — The Challenger Worth Watching

Jujutsu (invoked as jj) is a version control system developed at Google that uses a Git repository as its storage backend. This is its most important property: jj is not a replacement for Git. It is an alternative user interface to a Git repository, fully interoperable with Git remotes, GitHub, and anything else in the Git ecosystem. You can adopt it on one machine while colleagues continue using Git on the same repository.

Jujutsu rethinks the user-facing model with several significant improvements:

No staging area. Every file modification is automatically tracked as part of a “working copy commit.” The concept of git add and the index does not exist. You are always working on a commit in progress.

First-class conflicts. Git stores conflicts as conflict markers in files and requires manual resolution before proceeding. Jujutsu stores conflicts as first-class objects that can be committed, pushed, and propagated. You can rebase through a conflicting commit, and the conflict is carried forward until you choose to resolve it. This changes the entire feel of complex rebase operations — you can restructure history first and resolve conflicts at the end.

The operation log. Every action in jj — every commit, rebase, push, pull, even an undo — is recorded in an operation log. Undo is a first-class operation that can roll back any action, not just the last commit:

1
2
3
jj op log              # Show full operation history
jj op undo             # Undo the last operation
jj op restore @-5      # Restore state from 5 operations ago

Automatic rebase. When you amend or reorder commits in a stack, jj automatically rebases all dependent commits. This almost never causes conflicts because the conflict model is first-class.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Jujutsu basics
jj init --git-repo .    # Adopt an existing Git repo
jj status               # Show current working copy state
jj diff                 # Show all working copy changes
jj commit -m "message"  # Commit all tracked changes
jj log                  # Visual history (beautiful by default)
jj squash               # Squash working copy into parent
jj edit <rev>           # Travel to a commit and edit it
jj rebase -d main       # Rebase onto main
jj git push             # Push to the Git remote

Jujutsu is gaining traction rapidly in 2026, especially among developers who frequently work with large stacks of dependent commits (a common pattern with AI-assisted development). If you feel constrained by Git’s staging area and rebase ceremony, it is worth investing an afternoon to try it.

Fossil — The All-in-One

Fossil was created by D. Richard Hipp, the author of SQLite. It is a distributed VCS that bundles a bug tracker, wiki, forum, and web interface into a single portable binary. The entire repository is a SQLite database, queryable with SQL.

Fossil’s defining philosophy is that history is permanent. There is no --force, no rebase, no history rewriting of any kind. Every commit is immutable. This makes Fossil poorly suited to workflows that depend on history editing (most modern Git workflows), but excellent for projects where a complete, unambiguous audit trail is paramount.

SQLite itself is managed in a Fossil repository. If you are building a small, self-contained project that wants the VCS, issue tracker, and documentation in one place with no external dependencies, Fossil is worth a look.

VCS Comparison Summary

Feature Git SVN Mercurial Jujutsu Fossil
Distribution model Distributed Centralized Distributed Distributed (Git backend) Distributed
History rewriting Yes No Limited Yes (via Git backend) No
Staging area Yes N/A No No No
Conflict handling Manual Manual Manual First-class objects Manual
Binary file efficiency Poor Good Moderate Inherited from Git Good
Directory-level ACL No Yes No No No
Built-in issue tracker No No No No Yes
Git remote compatible Native Partial (git-svn) No Yes No
Ecosystem/tooling Enormous Moderate Small Growing Niche
2026 mindshare Dominant Legacy Declining Rising Niche

Part 2: Git’s Internal Model

Understanding Git’s data model makes advanced operations obvious rather than mysterious. Almost every confusing Git behavior becomes clear once you know what Git is actually storing.

The Four Object Types

Git stores everything in .git/objects/ as one of four types, all content-addressed by SHA-1 (transitioning to SHA-256) hash:

blob    — file content (no filename, no metadata)
tree    — a directory: list of (mode, name, hash) → blob or tree
commit  — pointer to a tree + parent commits + author/message
tag     — pointer to a commit with a GPG signature

Every commit points to one tree (the root of the working directory at that moment) and zero or more parent commits. A merge commit has two parents. The initial commit has zero.

1
2
3
4
5
# Explore Git's object store directly
git cat-file -t HEAD          # Show type of HEAD object
git cat-file -p HEAD          # Print the commit object
git cat-file -p HEAD^{tree}   # Print the root tree
git ls-tree HEAD              # List files at HEAD

Branches Are Just Pointers

A branch in Git is a file in .git/refs/heads/ containing a 40-character SHA-1 hash — the commit the branch currently points to. That is all a branch is. Creating a branch is creating a 41-byte file. Deleting a branch is deleting that file. The branch label moves forward with each new commit.

1
2
cat .git/refs/heads/main          # Print the SHA of the tip commit
cat .git/HEAD                     # Print the current branch name

HEAD is a reference to the current branch (or directly to a commit in “detached HEAD” state). When you commit, Git:

  1. Creates a blob for each changed file
  2. Creates tree objects representing the directory structure
  3. Creates a commit object pointing to the root tree and the current HEAD as parent
  4. Moves the current branch pointer to the new commit

This model explains why Git branching is cheap, why rebasing works the way it does, and why the reflog can recover anything.

The Index (Staging Area)

The staging area (also called the index) is a file at .git/index that represents the next commit in preparation. It is a snapshot of what the commit will look like when you run git commit.

Working tree → git add → Index → git commit → Repository
  • git diff — difference between working tree and index
  • git diff --staged (or --cached) — difference between index and HEAD
  • git diff HEAD — difference between working tree and HEAD (skips index)

The index exists because it enables partial commits: you can have modified files that are not staged, meaning not included in the next commit. This is powerful for crafting clean, atomic commits from messy exploratory work. It is also the source of most Git beginner confusion.


Part 3: Advanced Git Techniques

Interactive Rebase — History Editing

git rebase -i (interactive rebase) lets you rewrite any sequence of commits before pushing. It is the primary tool for producing clean commit history from exploratory work.

1
2
3
4
5
# Edit the last 5 commits
git rebase -i HEAD~5

# Edit commits since branching from main
git rebase -i $(git merge-base HEAD main)

The interactive rebase editor opens with a list of commits and available actions:

pick a1b2c3d feat: add user authentication
pick e4f5a6b fix: handle null email in signup
pick 7b8c9d0 WIP: half-done password reset
pick 1e2f3a4 fix password reset
pick 5b6c7d8 tests for password reset

Actions:

  • pick — keep the commit as-is
  • reword (r) — keep the commit but edit the message
  • edit (e) — pause at this commit to amend it
  • squash (s) — merge into the previous commit, combining messages
  • fixup (f) — merge into the previous commit, discarding this message
  • drop (d) — delete the commit entirely
  • exec (x) — run a shell command between commits (useful for running tests at each step)

Cleaning up the WIP commits above:

pick a1b2c3d feat: add user authentication
pick e4f5a6b fix: handle null email in signup
squash 7b8c9d0 WIP: half-done password reset
squash 1e2f3a4 fix password reset
squash 5b6c7d8 tests for password reset

This collapses the three password-reset commits into one.

Safety rules:

  1. Never rebase commits that have been pushed to a shared branch and pulled by others
  2. Always create a backup branch before complex rebases: git branch backup-before-rebase
  3. The reflog gives you 90 days to recover from any rebase disaster

Useful rebase flags:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
git rebase -i --autosquash HEAD~10
# Automatically arrange commits marked with "squash!" or "fixup!" prefixes
# Combine with: git commit --fixup=<hash> and git commit --squash=<hash>

git rebase -i --autostash HEAD~5
# Automatically stash/unstash uncommitted changes around the rebase

git rebase --onto target-branch source-branch feature-branch
# Transplant feature-branch onto target-branch, starting from where it diverged from source-branch
# Useful for moving a branch that was accidentally started from the wrong base

Reflog — The Safety Net

The reflog records where every reference (HEAD, branch tips) has pointed over the last 90 days (configurable via gc.reflogExpire). It is local to your repository and not pushed to remotes. It is your last line of defense against accidental data loss.

1
2
3
4
git reflog                    # Show HEAD movement history
git reflog show main          # Show main branch movement history
git reflog --all              # Show all references
git reflog --date=iso         # Show with timestamps

Common recovery scenarios:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Recover from an accidental git reset --hard
git reflog
# Find the SHA of where you were before
git reset --hard HEAD@{3}     # Go back 3 reflog entries
# or
git checkout -b recovery-branch HEAD@{3}

# Recover a deleted branch
git reflog
# Find the last commit of the deleted branch
git checkout -b recovered-branch <sha>

# Recover commits orphaned by a bad rebase
git reflog
git cherry-pick <orphaned-commit-sha>

The pattern for any Git disaster: run git reflog, find the SHA of where you want to be, and use git reset, git checkout, or git cherry-pick to get there.

Bisect — Binary Search for Bugs

git bisect uses binary search to find the commit that introduced a bug. Given a range of N commits, it requires at most log₂(N) tests to locate the culprit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
git bisect start
git bisect bad                   # Current commit is broken
git bisect good v2.4.0           # This tag was working

# Git checks out a midpoint commit
# Test it, then:
git bisect good    # or
git bisect bad

# Git narrows and checks out another midpoint
# Repeat until:
# --- Bisecting: 0 revisions left to test after this ---
# <sha> is the first bad commit

git bisect reset                 # Return to HEAD when done

Automated bisect — if you have a test that reproduces the bug, run it automatically:

1
2
3
4
git bisect start HEAD v2.4.0
git bisect run npm test -- --testPathPattern="auth"
# Exit code 0 = good, non-zero = bad
# Git runs the full binary search automatically

Automated bisect over 1000 commits finds the culprit in at most 10 test runs.

Stash — Temporary Shelving

The stash is a stack of temporary work-in-progress saves.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
git stash                        # Save all tracked changes
git stash push -m "wip: auth refactor"   # Named stash
git stash push --include-untracked       # Include untracked files
git stash push --patch           # Interactively choose hunks to stash

git stash list                   # Show all stashes
git stash show -p stash@{1}      # Show diff of a specific stash
git stash pop                    # Apply and drop the top stash
git stash apply stash@{2}        # Apply without dropping
git stash drop stash@{1}         # Delete a specific stash
git stash clear                  # Delete all stashes
git stash branch new-branch      # Create a branch from a stash

Prefer named stashes. git stash list with half a dozen stash@{N}: WIP on main: ... entries is a mystery. git stash push -m "wip: payment form validation before switching to auth" is self-documenting.

Worktrees — Multiple Branches Simultaneously

Git worktrees allow a single repository to be checked out in multiple directories at the same time, each on a different branch. This eliminates the need to stash, switch branches, and context-switch when reviewing a PR while mid-feature.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Add a worktree for a PR review
git worktree add ../project-pr-review origin/feat/payment-api
# Now you can cd ../project-pr-review and review without disturbing your work

# Add a worktree for a hotfix while continuing feature work
git worktree add ../project-hotfix -b hotfix/auth-null-pointer

# List all worktrees
git worktree list

# Remove when done
git worktree remove ../project-pr-review
git worktree prune    # Clean up stale worktree metadata

Each worktree has its own index and HEAD, but shares the object store (.git/objects). Branches used by an active worktree are locked — you cannot check them out in another worktree.

Worktrees are the foundation of multi-agent AI coding workflows (see Part 5).

Git Hooks — Automation at Every Stage

Git hooks are scripts in .git/hooks/ that execute automatically at specific lifecycle events. They are not committed to the repository by default — to share hooks with a team, keep them in a directory like .githooks/ and configure Git to use it:

1
git config core.hooksPath .githooks

Key hooks and what to use them for:

Hook Trigger Common Uses
pre-commit Before commit is created Run linter, formatter, tests
commit-msg After commit message is written Validate Conventional Commits format
prepare-commit-msg Before editor opens Pre-fill commit message from branch name
post-commit After commit is created Desktop notifications, logging
pre-push Before push to remote Run full test suite, check for secrets
post-merge After merge or pull Run npm install if package-lock.json changed
post-checkout After branch switch Same as post-merge

Example: pre-commit that blocks commits with debug statements:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/usr/bin/env bash
# .githooks/pre-commit

# Run ESLint on staged files
STAGED_JS=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|jsx|tsx)$')
if [ -n "$STAGED_JS" ]; then
  echo "$STAGED_JS" | xargs npx eslint --max-warnings=0
  if [ $? -ne 0 ]; then
    echo "ESLint failed. Commit aborted."
    exit 1
  fi
fi

# Block console.log in production code
if echo "$STAGED_JS" | xargs grep -l "console\.log" 2>/dev/null | grep -v '\.test\.' | grep -v spec; then
  echo "console.log found in non-test file. Remove before committing."
  exit 1
fi

Example: commit-msg enforcing Conventional Commits:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/usr/bin/env bash
# .githooks/commit-msg

COMMIT_MSG=$(cat "$1")
PATTERN="^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?(!)?: .{1,80}"

if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
  echo "Commit message must follow Conventional Commits format:"
  echo "  type(scope): description"
  echo ""
  echo "Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert"
  echo "Example: feat(auth): add JWT refresh token support"
  exit 1
fi

Husky is the most popular tool for managing Git hooks in Node.js projects — it installs hooks automatically when the project is set up:

1
2
3
npm install --save-dev husky lint-staged
npx husky init
# Creates .husky/ directory with hooks as shell scripts

Git Aliases — The .gitconfig Toolkit

A well-configured ~/.gitconfig makes daily Git work significantly faster.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
[user]
  name = Your Name
  email = you@example.com
  signingkey = YOUR_GPG_KEY_ID

[core]
  editor = nvim
  autocrlf = input        # Normalize line endings on commit
  whitespace = fix         # Fix trailing whitespace
  excludesfile = ~/.gitignore_global

[commit]
  gpgsign = true          # Sign all commits by default

[push]
  default = current        # Push current branch without specifying remote
  autoSetupRemote = true   # Automatically set upstream on first push

[pull]
  rebase = true           # Always rebase on pull, never merge

[rebase]
  autoStash = true        # Auto-stash before rebase
  autoSquash = true       # Auto-process fixup! and squash! commits

[merge]
  tool = vimdiff
  conflictstyle = diff3   # Show the common ancestor in conflict markers

[diff]
  algorithm = histogram   # More accurate diff algorithm than default Myers
  colorMoved = zebra      # Distinguish moved lines from additions/deletions

[log]
  date = iso

[rerere]
  enabled = true           # Remember conflict resolutions and replay them
  autoupdate = true

[alias]
  # Status and logging
  s    = status -sb
  ss   = status
  l    = log --oneline --graph --decorate -20
  ll   = log --oneline --graph --decorate --all
  lp   = log -p --follow
  ls   = log --stat --oneline

  # Branching
  b    = branch
  ba   = branch -a
  bd   = branch -d
  bD   = branch -D
  co   = checkout
  cob  = checkout -b
  sw   = switch
  swc  = switch -c

  # Common operations
  a    = add
  ap   = add -p             # Interactive hunk staging
  c    = commit
  cm   = commit -m
  ca   = commit --amend
  can  = commit --amend --no-edit
  cp   = cherry-pick

  # Stash
  st   = stash
  stp  = stash pop
  stl  = stash list
  sts  = stash show -p

  # Diff
  d    = diff
  dc   = diff --cached
  dh   = diff HEAD

  # Push/pull
  p    = push
  pf   = push --force-with-lease   # Safe force push
  pl   = pull
  plr  = pull --rebase

  # Worktrees
  wl   = worktree list
  wa   = worktree add
  wr   = worktree remove

  # Utilities
  undo   = reset --soft HEAD~1      # Undo last commit, keep changes staged
  unstage = reset HEAD --           # Unstage files
  discard = checkout --             # Discard working tree changes
  aliases = config --get-regexp ^alias
  whoami  = config user.email
  root    = rev-parse --show-toplevel

  # Find when a string was last changed
  pickaxe = log -S

  # Show files changed in a commit range
  changed = diff --name-only

  # Clean up merged branches
  cleanup = "!git branch --merged main | grep -v '\\* main' | xargs git branch -d"

  # Show contributors by commit count
  contributors = shortlog -sn --no-merges

  # Show most recently modified branches
  recent = "!git for-each-ref --sort=-committerdate refs/heads/ --format='%(refname:short) %(committerdate:short) %(subject)' | head -20"

Commit Messages — Conventional Commits

Commit messages are documentation. They answer a question that no other source can answer well: why was this change made? The code shows what changed. The commit message explains the reasoning.

The Conventional Commits specification is the widely adopted standard format:

<type>[optional scope][optional !]: <description>

[optional body]

[optional footer(s)]

Types:

  • feat — introduces a new feature (MINOR version bump in semver)
  • fix — patches a bug (PATCH version bump)
  • feat! or fix! — breaking change (MAJOR version bump, also BREAKING CHANGE: in footer)
  • docs — documentation changes only
  • style — formatting, whitespace; no logic change
  • refactor — code restructuring; no feature or bug change
  • perf — performance improvement
  • test — adding or correcting tests
  • chore — maintenance (updating deps, build config, etc.)
  • ci — CI/CD configuration changes
  • build — build system changes
feat(auth): add OAuth2 PKCE flow for mobile clients

Replace the implicit grant with PKCE (Proof Key for Code Exchange) to
eliminate the authorization code interception attack vector on mobile.

The client generates a code_verifier and sends the hash (code_challenge)
with the authorization request. The verifier is sent with the token request,
and the server verifies the pair.

Closes #487
BREAKING CHANGE: The /oauth/token endpoint now requires code_verifier for
public clients. See docs/oauth-migration.md for upgrade steps.

The body explains the why and what was considered. Anyone reading this commit in six months understands the security motivation and knows where to find migration documentation. A commit message of fix oauth achieves none of this.

Conventional Commits enables automated tooling:

  • standard-version and release-please auto-generate changelogs and bump versions
  • Branch names like feat/auth-pkce + PR titles in Conventional Commits format integrate with GitHub’s release automation
  • Semantic-release (GitHub Actions) reads commit history to publish packages with the correct version

Signed Commits

Signing commits with GPG or SSH proves authorship — without a signature, nothing prevents someone from impersonating you in a commit:

1
git commit --author="Linus Torvalds <torvalds@linux-foundation.org>" -m "backdoor"

GPG signing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Generate a GPG key
gpg --full-generate-key

# List keys to find your key ID
gpg --list-secret-keys --keyid-format=long
# Example: sec rsa4096/3AA5C34371567BD2

# Configure Git to sign all commits
git config --global user.signingkey 3AA5C34371567BD2
git config --global commit.gpgsign true
git config --global tag.gpgSign true

# Export public key and add to GitHub Settings → SSH and GPG keys
gpg --armor --export 3AA5C34371567BD2

SSH signing (simpler, recommended for new setups):

1
2
3
4
5
6
7
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true

# For verification to work locally, create an allowed_signers file
echo "you@example.com $(cat ~/.ssh/id_ed25519.pub)" >> ~/.ssh/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers

GitHub displays a green “Verified” badge on signed commits and supports enforcing signed commits via branch protection rules.


Part 4: GitHub Platform Features

Branch Protection Rules

Branch protection rules are the single most impactful thing you can do to prevent common team Git disasters. Configure them under Settings → Branches → Add rule.

Recommended settings for main:

✓ Require a pull request before merging
  ✓ Require approvals: 1 (solo projects) or 2 (teams)
  ✓ Dismiss stale reviews when new commits are pushed
  ✓ Require review from Code Owners

✓ Require status checks to pass before merging
  ✓ Require branches to be up to date before merging
  [Add your CI checks: build, test, lint]

✓ Require conversation resolution before merging

✓ Require signed commits

✓ Require linear history (prevents merge commits, enforces rebase/squash)

✓ Do not allow bypassing the above settings

A CODEOWNERS file maps paths to responsible reviewers:

# .github/CODEOWNERS
*                      @org/engineering          # Everyone must approve
/src/auth/             @org/security-team        # Auth changes need security review
/infra/                @org/platform-team        # Infra changes need platform review
/docs/                 @org/technical-writing    # Docs need tech writing review
*.github/workflows/    @org/devops               # CI changes need devops review

GitHub Actions — CI/CD in 150 Lines

GitHub Actions runs CI/CD workflows defined as YAML in .github/workflows/. Every workflow is triggered by an event (push, PR, schedule, manual dispatch) and runs jobs consisting of steps.

Production-ready Node.js CI workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true    # Cancel redundant runs on the same branch

jobs:
  test:
    name: Test (Node ${{ matrix.node }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [20, 22]

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm

      - run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

      - name: Unit tests
        run: npm run test:unit -- --coverage

      - name: Integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb

      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  security:
    name: Security scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: github/super-linter@v6
      - uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

  deploy:
    name: Deploy to staging
    needs: [test, security]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment:
      name: staging
      url: https://staging.yourapp.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: ./scripts/deploy.sh staging
        env:
          DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}

Useful Actions patterns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Cache Docker layers for faster builds
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

# Run steps only when specific files change
- uses: dorny/paths-filter@v3
  id: changes
  with:
    filters: |
      backend:
        - 'src/api/**'
      frontend:
        - 'src/ui/**'
- if: steps.changes.outputs.backend == 'true'
  run: npm run test:api

# Create GitHub releases automatically
- uses: googleapis/release-please-action@v4
  with:
    release-type: node
    # Reads Conventional Commits, bumps version, generates changelog

Secrets and Environment Management

GitHub Actions secrets are available via ${{ secrets.SECRET_NAME }} and are masked in logs. Best practices:

  • Use environments (Settings → Environments) for staging/production separation — each environment has its own secrets and can require approval before deployment
  • Rotate secrets on schedule — create an Actions workflow that runs weekly and alerts if secrets haven’t been rotated in 90 days
  • Audit secret access — the audit log shows every time a secret is accessed
  • Use OIDC for cloud providers — instead of storing long-lived AWS/GCP/Azure keys as secrets, configure OIDC trust and exchange short-lived tokens at runtime:
1
2
3
4
5
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
    aws-region: us-east-1
    # No long-lived access keys needed — OIDC issues a temporary credential

GitHub Copilot CLI

GitHub Copilot CLI reached general availability on February 25, 2026. It brings Copilot directly into the terminal as an agentic coding environment.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Install
npm install -g @github/copilot-cli
gh extension install github/gh-copilot

# Basic usage
gh copilot suggest "find all files modified in the last 7 days larger than 1MB"
gh copilot explain "git rebase -i --autosquash --autostash HEAD~10"

# Agentic: Plan mode (analyzes requirements before writing code)
gh copilot "add rate limiting to the /api/auth/* endpoints"

# Agentic: Run a background task (& prefix)
gh copilot "& write unit tests for all untested functions in src/services/"

# Context inspection
/context    # Show token usage and loaded context

# Clear context between unrelated tasks
/clear

Copilot CLI in 2026 supports parallel specialized agents (Explore, Task, Code Review, Plan) and cross-session memory — it learns your codebase conventions over time.

Dependabot and Security Automation

GitHub’s Dependabot automatically opens PRs to update dependencies when vulnerabilities are discovered. Configure it in .github/dependabot.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
      day: monday
    labels:
      - dependencies
    open-pull-requests-limit: 10
    groups:
      dev-dependencies:
        patterns:
          - "*"
        dependency-type: development
      production-dependencies:
        patterns:
          - "*"
        dependency-type: production

  - package-ecosystem: docker
    directory: /
    schedule:
      interval: weekly

  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

Enable secret scanning and push protection under Settings → Security → Code security: if a developer accidentally commits an API key or private key, GitHub blocks the push and alerts immediately.

GitHub Codespaces

Codespaces provide cloud-hosted development environments defined by a .devcontainer/devcontainer.json file. Every contributor gets the same environment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// .devcontainer/devcontainer.json
{
  "name": "MyApp Dev",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:22",
  "features": {
    "ghcr.io/devcontainers/features/git:1": {},
    "ghcr.io/devcontainers/features/github-cli:1": {},
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  "forwardPorts": [3000, 5432],
  "postCreateCommand": "npm ci && npx prisma generate",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "prisma.prisma"
      ]
    }
  },
  "secrets": {
    "DATABASE_URL": { "description": "Connection string for the dev database" }
  }
}

Codespaces are particularly powerful for:

  • Onboarding new contributors with zero local setup
  • Reviewing PRs in a running environment
  • Running AI coding agents in isolated cloud environments

Part 5: AI Agents in Version Control Workflows

The Shift to Agentic Git

AI coding assistants in 2026 are no longer just inline autocomplete. They are autonomous agents that interact directly with Git: creating branches, staging changes, writing commit messages, opening pull requests, responding to review comments, and running CI checks — with humans reviewing at key checkpoints rather than writing every line.

As of February 2026, GitHub Copilot natively supports Claude (Anthropic) and Codex (OpenAI) as coding agents directly within GitHub, using a shared platform with unified governance and memory. You can assign a GitHub issue to @copilot, @claude, or @codex and the agent will:

  1. Read the issue and any linked context
  2. Explore the repository to understand the affected code
  3. Create a branch and implement the changes
  4. Push commits with meaningful commit messages
  5. Open a draft PR with a description explaining what was done and why
  6. Request your review

You review the PR, leave comments, and the agent iterates. Your existing branch protection rules, CI checks, and CODEOWNERS requirements still apply — the agent cannot bypass them.

Claude Code and Git Worktrees

Claude Code (Anthropic’s terminal-based coding agent, covered in depth in our Home Lab AI Agents post) has native integration with git worktrees for parallel agent sessions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Each Claude session gets an isolated worktree and branch
claude --worktree feat-payment-api
claude --worktree fix-auth-null-deref
claude --worktree refactor-db-queries

# Check their status
git worktree list
# main-checkout           /home/user/project              [main]
# .claude/worktrees/feat-payment-api  /home/user/project/.claude/worktrees/feat-payment-api  [claude/feat-payment-api]
# .claude/worktrees/fix-auth-null-deref  ...  [claude/fix-auth-null-deref]

Each agent works on its own branch. When it completes, you review the diff:

1
git diff main...claude/feat-payment-api

And merge if the output looks good:

1
2
3
git checkout main
git merge claude/feat-payment-api --no-ff -m "feat(payments): add Stripe payment intent API"
git worktree remove .claude/worktrees/feat-payment-api

CLAUDE.md — Context for AI Agents

Every repository that AI agents work in benefits from a CLAUDE.md file at the root. This file is read by Claude Code at the start of every session and serves as the agent’s briefing document — your project’s conventions, constraints, and architecture decisions, all in one place.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# CLAUDE.md — Project Agent Instructions

## Tech Stack
- Runtime: Node.js 22 + TypeScript 5.3
- Framework: Fastify 4 (NOT Express)
- ORM: Drizzle ORM — never write raw SQL in route handlers
- Test framework: Vitest (NOT Jest)
- Database: PostgreSQL 16

## Code Conventions
- TypeScript strict mode — no `any`
- All monetary values use `decimal.js` — never native floats
- All env variables loaded via `src/config/env.ts` — never `process.env` directly
- Errors thrown as `AppError` instances from `src/errors/AppError.ts`
- No `console.log` in non-test code — use `src/utils/logger.ts`

## Testing Requirements
- Every new route file must have a corresponding test in `src/tests/routes/`
- Unit test coverage must stay above 80%
- Run tests: `npm test`
- Run specific file: `npm test -- auth.test.ts`

## Git Conventions
- Conventional Commits: `feat(scope): description`
- Branch names: `feat/`, `fix/`, `chore/`, `refactor/`
- Never commit directly to `main`

## Common Commands
- `npm run dev` — start dev server
- `npm run migrate` — run pending DB migrations
- `npm run migrate:create` — create new migration
- `docker compose up -d` — start PostgreSQL + Redis
- `npm run build` — compile TypeScript

## Files NOT to Modify Without Asking
- `src/db/schema.ts` — schema changes require a migration
- `src/config/env.ts` — adding env vars requires `.env.example` update too
- `.github/workflows/` — CI changes need review

The more specific your CLAUDE.md, the fewer wrong turns agents take. Time invested in it is repaid on every agent session.

AI-Generated Commit Messages

AI agents write commit messages as part of their workflow. You can also use AI to write commit messages for your own changes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Using Claude Code's /commit command
# It reads your staged diff and writes a Conventional Commits message

# Using gh copilot
git diff --staged | gh copilot explain - --commit-message

# Using a shell function with the Anthropic API (example)
gcai() {
  local diff
  diff=$(git diff --staged)
  if [ -z "$diff" ]; then
    echo "Nothing staged to commit."
    return 1
  fi
  local message
  message=$(echo "$diff" | claude --print \
    "Write a Conventional Commits format commit message for this diff.
     Output only the message, no explanation.")
  git commit -m "$message"
}

Review AI commit messages before accepting. AI agents tend to describe what changed accurately but may miss why it was necessary. Add context for decisions that are not apparent from the diff.

AI-Assisted Code Review

GitHub Copilot’s code review agent runs on PRs automatically, providing initial feedback before human review:

1
2
3
4
5
6
7
# .github/copilot-instructions.md
When reviewing pull requests:
- Check that all new functions have corresponding unit tests
- Flag any direct database queries outside of src/db/queries/
- Verify that error handling follows the AppError pattern
- Check for any hardcoded values that should be environment variables
- Look for potential N+1 query problems

For @claude review in PR comments:

@claude please review the changes in src/services/payment.ts for:
1. Correct use of decimal.js for all monetary arithmetic
2. Any missing error cases in the Stripe webhook handler
3. Whether the retry logic could cause duplicate charges

The agent reads the PR diff, the relevant surrounding code, and responds with specific, file:line-level feedback.

Combining Agents: The Parallel Workflow

The practical multi-agent vibecoding workflow with Git:

1. Decompose work into 3-4 independent tasks
2. Write task specs in issues or as CLAUDE.md task files
3. Spawn one agent per task with git worktrees:
     claude --worktree feat-payments --tmux
     claude --worktree fix-auth --tmux
     claude --worktree refactor-queries --tmux
4. Monitor in tmux, intervene when agents get stuck
5. Review each diff: git diff main...claude/<branch>
6. Run CI locally on each worktree: cd .claude/worktrees/feat-payments && npm test
7. Merge passing branches, discard or redirect failing ones
8. Open PR, let Copilot's review agent do the first pass
9. Do final human review, merge

The human role: writing task specs, reviewing output, making architectural decisions, and resolving the cases where agents need clarification. The typing is mostly automated.


Part 6: Self-Hosted Git in the Home Lab

Why Self-Host

Self-hosting your Git platform gives you:

  • Privacy — private code stays on your hardware
  • Cost control — no per-user pricing for large teams of personal projects
  • Experimentation — try workflow changes without affecting your professional GitHub account
  • Learning — running Gitea + Woodpecker CI is an excellent way to understand CI/CD deeply
  • Air-gap capability — code and CI run without internet for sensitive projects

The 2026 recommendation for home labs: Forgejo or Gitea as the Git host, Woodpecker CI or Gitea Actions for CI/CD.

Gitea and Forgejo

Gitea and Forgejo are functionally nearly identical — Forgejo is a community fork of Gitea with different governance. Both are:

  • Written in Go, single-binary deployments
  • Extremely lightweight: a full instance runs in under 100 MB RAM
  • GitHub-like UI: repositories, issues, pull requests, organizations, webhooks
  • Compatible with GitHub Actions workflows via built-in Actions support
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# docker-compose.yml — Gitea or Forgejo
services:
  gitea:
    image: gitea/gitea:latest    # or codeberg.org/forgejo/forgejo:latest
    container_name: gitea
    restart: always
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=db:5432
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=${DB_PASSWORD}
      - GITEA__server__DOMAIN=gitea.lab.internal
      - GITEA__server__ROOT_URL=https://gitea.lab.internal
      - GITEA__server__SSH_DOMAIN=gitea.lab.internal
      - GITEA__server__SSH_PORT=2222
      - GITEA__service__DISABLE_REGISTRATION=true   # Private instance
      - GITEA__security__SECRET_KEY=${SECRET_KEY}
    volumes:
      - gitea_data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "2222:22"    # SSH on non-standard port
    depends_on:
      - db

  db:
    image: postgres:16
    restart: always
    environment:
      - POSTGRES_USER=gitea
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=gitea
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  gitea_data:
  postgres_data:

After starting, complete the web installer and create your admin account. Then add your SSH key (same as you would on GitHub) and configure your remotes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Add your local Gitea as a remote alongside GitHub
git remote add local ssh://git@gitea.lab.internal:2222/youruser/myproject.git

# Push to both simultaneously
git push origin main
git push local main

# Or configure push to multiple remotes at once:
git remote set-url --add --push origin ssh://git@gitea.lab.internal:2222/youruser/myproject.git
git push origin main   # Now pushes to both GitHub and Gitea

Woodpecker CI

Woodpecker CI is a lightweight, community-owned CI/CD system that integrates directly with Gitea/Forgejo via OAuth. Pipelines are defined as .woodpecker.yml in the repository root.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# docker-compose.yml — Woodpecker CI server + agent
services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:latest
    restart: always
    environment:
      - WOODPECKER_OPEN=false
      - WOODPECKER_HOST=https://ci.lab.internal
      - WOODPECKER_GITEA=true
      - WOODPECKER_GITEA_URL=https://gitea.lab.internal
      - WOODPECKER_GITEA_CLIENT=${GITEA_OAUTH_CLIENT}
      - WOODPECKER_GITEA_SECRET=${GITEA_OAUTH_SECRET}
      - WOODPECKER_AGENT_SECRET=${AGENT_SECRET}
      - WOODPECKER_DATABASE_DRIVER=postgres
      - WOODPECKER_DATABASE_DATASOURCE=postgres://woodpecker:${DB_PASS}@db:5432/woodpecker?sslmode=disable
    ports:
      - "9000:9000"
    depends_on:
      - db

  woodpecker-agent:
    image: woodpeckerci/woodpecker-agent:latest
    restart: always
    command: agent
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=${AGENT_SECRET}
      - WOODPECKER_BACKEND=docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  db:
    image: postgres:16
    restart: always
    environment:
      - POSTGRES_USER=woodpecker
      - POSTGRES_PASSWORD=${DB_PASS}
      - POSTGRES_DB=woodpecker

Pipeline definition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# .woodpecker.yml
when:
  - event: [push, pull_request]

steps:
  lint:
    image: node:22-alpine
    commands:
      - npm ci
      - npm run lint

  test:
    image: node:22-alpine
    environment:
      DATABASE_URL: postgres://testuser:testpass@db:5432/testdb
    commands:
      - npm ci
      - npm run test -- --coverage

  build:
    image: node:22-alpine
    commands:
      - npm ci
      - npm run build
    when:
      branch: main

  deploy:
    image: alpine
    secrets:
      - ssh_deploy_key
    commands:
      - apk add openssh-client
      - echo "$SSH_DEPLOY_KEY" > /tmp/key && chmod 600 /tmp/key
      - ssh -i /tmp/key deploy@production.lab.internal "./deploy.sh"
    when:
      branch: main
      event: push

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
      POSTGRES_DB: testdb

GitOps with Gitea + Ansible

For managing home lab infrastructure configuration as code, combine Gitea with Ansible:

Gitea repository
└── ansible/
    ├── playbooks/
    │   ├── update-containers.yml
    │   ├── configure-proxmox.yml
    │   └── deploy-services.yml
    ├── inventory/
    │   └── homelab.yml
    └── roles/
        ├── gitea/
        ├── ollama/
        └── monitoring/

Woodpecker CI runs Ansible playbooks automatically when you push configuration changes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# .woodpecker.yml in your infrastructure repo
steps:
  ansible-lint:
    image: cytopia/ansible-lint
    commands:
      - ansible-lint ansible/playbooks/

  deploy-infra:
    image: cytopia/ansible
    secrets:
      - ansible_vault_password
      - ssh_private_key
    commands:
      - echo "$ANSIBLE_VAULT_PASSWORD" > /tmp/vault_pass
      - echo "$SSH_PRIVATE_KEY" > /tmp/ssh_key && chmod 600 /tmp/ssh_key
      - ansible-playbook -i ansible/inventory/homelab.yml
          --vault-password-file /tmp/vault_pass
          --private-key /tmp/ssh_key
          ansible/playbooks/deploy-services.yml
    when:
      branch: main
      event: push

Every infrastructure change goes through a PR reviewed by you, executed automatically via CI, and recorded in Git history.

Connecting Home Lab Git to Tailscale

With Tailscale running on your lab machines (see our Home Lab AI Agents post), your self-hosted Gitea is accessible from anywhere via SSH:

1
2
3
4
5
# From anywhere on the tailnet (home, travel, coffee shop)
git clone ssh://git@gitea.lab.internal:2222/youruser/myproject.git

# Or via Tailscale hostname
git clone ssh://git@your-server.tail12345.ts.net:2222/youruser/myproject.git

Configure your SSH config for clean short names:

# ~/.ssh/config
Host gitea
  HostName gitea.lab.internal
  Port 2222
  User git
  IdentityFile ~/.ssh/id_ed25519

Host gitea-remote
  HostName your-server.tail12345.ts.net
  Port 2222
  User git
  IdentityFile ~/.ssh/id_ed25519

Then: git clone gitea:youruser/myproject


Part 7: Workflows and Productivity Stack

Branching Workflows: Trunk-Based, GitHub Flow, and GitFlow

Choosing a branching strategy shouldn’t require a PhD, yet teams routinely either overcomplicate it or run with no strategy at all. There are really only three patterns worth knowing, and they differ mostly in how many long-lived branches they tolerate and how explicitly they manage releases. Everything else is a variation on these.

Trunk-based development. Everyone commits to main directly, or through branches that live for hours rather than days. Incomplete work is hidden behind feature flags rather than stashed on a branch, CI runs on every commit, and a release is just a tag.

main: ──●──●──●──●──●──●──●──

This is the workflow that scales best in both directions — it works for a solo developer and it is what Google and the larger continuous-deployment shops run at enormous scale — but it demands discipline and real test coverage, because there is no integration branch to catch a bad merge before it reaches main. The feature-flag machinery is the tax you pay: incomplete features ship to production disabled, and dead flags have to be cleaned up or they rot into permanent conditional complexity.

GitHub Flow. Branch off main, make changes, open a pull request, get review, merge back. There is exactly one long-lived branch (main), and everything else is a short-lived topic branch.

main:    ──●──────●──────●──────●──
            \    /        \    /
feature:     ●──●          ●──●
1
2
3
4
git checkout -b feature/user-auth
# make changes, commit
git push -u origin feature/user-auth
# open PR, get review, squash-merge to main

This is the default for most teams for good reason: it is simple enough to follow consistently while still providing code review and a clean, auditable history. Its only real failure mode is branches that overstay their welcome — a feature branch that lives for three weeks accumulates merge conflicts and defeats the point. Keep branches measured in commits, not weeks.

GitFlow. A structured model with dedicated long-lived branches: main holds production code, develop is the integration branch, feature branches merge into develop, release branches fork from develop and merge into both main and develop, and hotfix branches fork from main.

main:    ──●───────────────●───────●──
            \             /       /
release:     \       ●───●       /
              \     /           /
develop: ──●───●───●───●───●───●──
            \     /
feature:     ●───●

GitFlow earns its complexity only when you genuinely ship scheduled releases and support multiple versions in production simultaneously — packaged software, firmware, anything with a real release calendar. For a web app that deploys continuously, it is pure overhead: the develop branch becomes a second source of truth that drifts from main, and the long-lived branches generate exactly the merge conflicts the model was supposed to prevent.

Workflow Long-lived branches Release management Best fit
Trunk-based main only Tag on main Solo devs and high-discipline CD teams
GitHub Flow main only Deploy on merge Most teams, 2–15 developers
GitFlow main + develop Release branches Scheduled releases, multiple supported versions

The recommendation by team size is simple. Solo or pair (1–2 people): trunk-based — the ceremony of branches and PRs isn’t worth it when you can just talk. Small team (3–8): GitHub Flow — simple enough to follow consistently, with review and clean history. Larger team (8+): start with GitHub Flow and add structure only where you feel specific pain; reach for GitFlow only if you have explicit release cycles.

If you need to support an old version without taking on full GitFlow, add a single release branch to GitHub Flow and cherry-pick hotfixes onto it:

main:      ──●──●──●──●──●──●──
              \
release/1.0:   ●──●──●  (hotfixes only)

That buys you GitFlow’s release isolation without a permanent develop branch to keep in sync. Whichever model you pick, the supporting disciplines are the same ones covered earlier in this post: keep branches short-lived, rebase before you push (see Part 3), write Conventional Commits (see Part 4), and protect main with required reviews and status checks (see Part 4). The branching diagram matters far less than the consistency with which the team follows it — a simple workflow everyone respects beats a sophisticated one people route around.

The Daily Workflow

With the full stack running — GitHub for public/team projects, Gitea for private experiments, Claude Code for autonomous implementation, and Woodpecker CI for automated testing — the daily workflow looks like:

Morning
├── git pull --rebase on active branches
├── Review overnight agent work in tmux
├── Merge or redirect based on review
└── Queue new agent tasks for the day

Throughout the day
├── Work directly on small tasks (< 30 min)
├── Delegate medium tasks to Claude with worktrees
├── Review agent output as it completes
└── Push to Gitea for local backup; push to GitHub when ready

When submitting work
├── git rebase -i to clean up history
├── Verify commit messages follow Conventional Commits
├── Push branch, open PR
├── Let Copilot review agent do initial pass
└── Address review feedback (human or AI)

Essential Modern Git Tools

Beyond the core git CLI, these tools make daily Git work faster:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# lazygit — terminal UI for Git (replaces most git commands)
brew install lazygit
# Launch with: lazygit

# delta — better diffs (integrates with git diff, log, show)
brew install git-delta
# ~/.gitconfig:
# [core]
#   pager = delta
# [delta]
#   navigate = true
#   side-by-side = true

# gh — GitHub's official CLI
brew install gh
gh auth login
gh pr create --fill        # Create PR with branch name as title
gh pr checkout 123         # Check out PR #123 locally
gh pr review --approve     # Approve a PR from the terminal
gh run list                # List recent Actions runs
gh run watch               # Watch a running Actions workflow

# tig — text-mode interface for Git, powerful blame and log browsing
brew install tig
# Launch with: tig (repository browser) or tig blame src/auth.ts

# git-absorb — automatically absorb staged changes into fixup commits
cargo install git-absorb
git absorb    # Figures out which commits each staged hunk belongs to,
              # creates fixup! commits automatically
              # Follow with: git rebase -i --autosquash

# onefetch — beautiful repository summary
cargo install onefetch
onefetch      # Show language breakdown, contributor stats, recent activity

# difftastic — structural diff (understands syntax, not just text)
brew install difftastic
# GIT_EXTERNAL_DIFF=difft git diff

The .gitignore Global File

A global .gitignore that applies to all repositories prevents constantly re-adding OS and editor noise:

# ~/.gitignore_global
# Configure with: git config --global core.excludesfile ~/.gitignore_global

# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
.Spotlight-V100
.Trashes

# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*

# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

# Editors
.vscode/
!.vscode/extensions.json
!.vscode/settings.json    # Only if not project-specific
.idea/
*.swp
*.swo
*~
.vim/
*.sublime-project
*.sublime-workspace

# AI coding tools
.claude/
!.claude/CLAUDE.md        # Keep CLAUDE.md in version control
.cursor/
.copilot/

# Environment files
.env
.env.local
.env.*.local

# Build outputs
node_modules/
dist/
build/
out/
.next/
.nuxt/

# Runtime
*.pid
*.seed
*.log
.cache/

# Secrets (belt and suspenders — also use git-secrets or truffleHog)
*.pem
*.key
*.p12
*.pfx
*_rsa
*_ecdsa
*_ed25519

The Core Principles

After all the tools and configurations, the things that matter most are fewer and simpler:

Commit atomically. Each commit should represent one logical change that can be described in a single sentence and reverted without unintended side effects. A commit that changes authentication, updates dependencies, and reformats the codebase is three commits that got lost.

Commit messages are for humans reading history. Write them for the person — possibly yourself in six months — who needs to understand why the codebase changed. The diff shows what changed. The message explains why.

The reflog is always your safety net. Nothing you do in a local repository is truly irreversible for 90 days. Before any complex operation, know that git reflog can find where you were.

Rebase before you push, merge after you push. Keep your local branch clean with interactive rebase; use merge (or GitHub’s squash merge) for the final integration to preserve the fact that a change was reviewed.

Keep main releasable. Whatever branching strategy you use, every commit on main should represent a state that could be deployed to production without emergency fixes. This discipline, more than any specific workflow, is what separates teams that ship confidently from teams that dread releases.

Version control is your undo history for the entire codebase. Use it aggressively. Commit early, commit often on feature branches. Polish history before merging. Never worry about losing work.


Further Reading on This Blog

Comments