Skip to main content

Git and GitHub Mastery: Beyond the Basics

Advanced Git techniques and GitHub workflows for developers who already know the basics — covering interactive rebase, bisect, GitHub Actions, and more.

Priya Patel
15 min read
Git and GitHub Mastery: Beyond the Basics

You Know git add, git commit, git push. Now What?

Every developer tutorial starts with the same three commands. Add, commit, push. Maybe throw in a git pull and git merge for good measure. And honestly, you can survive on those commands for a long time. Most developers do.

But "surviving" and "thriving" are different things. I have watched senior developers untangle nightmarish merge conflicts in minutes that would take juniors hours. I have seen a single git bisect command find a bug that a team spent two days hunting manually. I have worked on teams where GitHub Actions replaced entire DevOps workflows.

The gap between a developer who knows basic Git and one who has truly mastered it is enormous. This guide bridges that gap. I am assuming you already understand branching, merging, and pull requests. We are going deeper.


Interactive Rebase: Rewriting History Like a Pro

Interactive rebase is the single most powerful Git feature most developers never use. It lets you edit, reorder, squash, split, and reword commits before they are shared with others.

When to Use It

You have been working on a feature branch. Your commit history looks like this:

fix typo in variable name
add user validation
WIP: trying something
actually fix the bug from earlier
add user validation (forgot test file)
clean up console.logs

That is six commits, and half of them are noise. Before merging into main, you want a clean history. Interactive rebase to the rescue:

git rebase -i HEAD~6

This opens your editor with:

pick a1b2c3d fix typo in variable name
pick e4f5g6h add user validation
pick i7j8k9l WIP: trying something
pick m0n1o2p actually fix the bug from earlier
pick q3r4s5t add user validation (forgot test file)
pick u6v7w8x clean up console.logs

Change it to:

pick a1b2c3d fix typo in variable name
pick e4f5g6h add user validation
squash q3r4s5t add user validation (forgot test file)
drop i7j8k9l WIP: trying something
pick m0n1o2p actually fix the bug from earlier
drop u6v7w8x clean up console.logs

The result: three clean commits instead of six messy ones. The squash command merged the forgotten test file into the validation commit. The drop command removed the work-in-progress and cleanup commits entirely.

The Available Commands

CommandWhat It Does
pickKeep the commit as-is
rewordKeep the commit but edit the message
editPause at this commit for amendments
squashMerge into the previous commit, combine messages
fixupMerge into the previous commit, discard this message
dropRemove the commit entirely

The Golden Rule

Never rebase commits that have been pushed to a shared branch. Interactive rebase rewrites commit hashes. If other developers have based work on those commits, rewriting them creates divergent histories and serious headaches. Use it only on your local feature branches before merging.


Cherry-Picking: Surgical Commit Transplants

Cherry-picking applies a specific commit from one branch to another. Unlike merging (which brings all commits), cherry-pick takes exactly the commits you want.

# Apply a specific commit to your current branch
git cherry-pick abc1234

# Cherry-pick multiple commits
git cherry-pick abc1234 def5678

# Cherry-pick a range
git cherry-pick abc1234..def5678

Real-World Use Case

You are on the release-2.0 branch and discover a critical bug. You fix it on main with commit fix-critical-auth-bypass. You need that fix in the release branch immediately, but you do not want to merge all of main (which has untested new features). Cherry-pick that single commit:

git checkout release-2.0
git cherry-pick fix-critical-auth-bypass

Done. The bug fix is now on both branches without dragging in anything else.

Handling Cherry-Pick Conflicts

If the cherry-picked commit conflicts with the target branch:

# Fix conflicts in the affected files
git add resolved-file.ts
git cherry-pick --continue

# Or abort if things get messy
git cherry-pick --abort

Git Bisect: Binary Search for Bugs

This is my favorite Git command, and I am genuinely surprised how few developers know about it. git bisect uses binary search to find exactly which commit introduced a bug.

Say your application worked fine two weeks ago but is broken now. There are 100 commits in between. Testing each one manually would take hours. Bisect does it in about 7 steps (log2 of 100).

# Start bisecting
git bisect start

# Mark the current commit as bad (has the bug)
git bisect bad

# Mark a known good commit (before the bug)
git bisect good abc1234

Git checks out the midpoint commit. You test if the bug exists:

# If this commit has the bug:
git bisect bad

# If this commit is fine:
git bisect good

Git narrows the range and checks out another midpoint. After 6-7 iterations, it identifies the exact commit that introduced the bug. Then:

# View the problematic commit
git show <bad-commit-hash>

# End bisecting and return to your original branch
git bisect reset

Automated Bisect

If you have a test script that returns 0 for pass and non-zero for fail, you can automate the entire process:

git bisect start
git bisect bad HEAD
git bisect good abc1234
git bisect run npm test

Git runs the test at each step automatically and identifies the breaking commit without any manual intervention. This is absurdly powerful for regression testing.


Stashing Strategies

git stash saves your uncommitted changes temporarily. Most developers know git stash and git stash pop. But there is more to it.

Named Stashes

# Stash with a descriptive message
git stash push -m "WIP: refactoring auth middleware"

# List all stashes
git stash list
# stash@{0}: On main: WIP: refactoring auth middleware
# stash@{1}: On feature: experimental API changes

# Apply a specific stash without removing it
git stash apply stash@{1}

# Apply and remove a specific stash
git stash pop stash@{1}

Partial Stashing

You can stash specific files instead of everything:

# Stash only specific files
git stash push -m "stash the config changes" src/config.ts src/env.ts

# Interactively choose which hunks to stash
git stash push -p

Stash + Branch

Create a new branch from a stash — useful when you realize your stashed work deserves its own branch:

git stash branch new-feature-branch stash@{0}

This creates a new branch from the commit where you originally stashed, applies the stash, and drops it from the stash list. Clean and elegant.


Git Worktrees: Multiple Branches, Simultaneously

Have you ever needed to check something on another branch but did not want to stash your current work or commit half-finished changes? Worktrees solve this by letting you check out multiple branches simultaneously in separate directories.

# Create a worktree for the hotfix branch
git worktree add ../project-hotfix hotfix-branch

# Now you have two working directories:
# ./project/          -> your current branch
# ./project-hotfix/   -> hotfix-branch

# Work on the hotfix without touching your main work
cd ../project-hotfix
# ... make fixes, commit, push ...

# When done, remove the worktree
git worktree remove ../project-hotfix

I use worktrees constantly for code reviews. Instead of stashing my work and checking out the PR branch, I create a worktree, review the code in a separate VS Code window, and delete the worktree when done. My main work is completely undisturbed.

# List all active worktrees
git worktree list

# Prune stale worktree references
git worktree prune

GitHub Actions: CI/CD Without the Headache

GitHub Actions lets you automate workflows triggered by repository events — pushes, pull requests, schedules, manual triggers. If you are still running tests manually before merging PRs, this section will change your life.

A Basic CI Workflow

Create .github/workflows/ci.yml in your repository:

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

      - name: Build
        run: npm run build

Every push to main and every pull request now automatically runs linting, tests, and builds. If any step fails, the PR is marked with a red X. No more "it works on my machine" excuses.

Deployment Workflow

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install and Build
        run: |
          npm ci
          npm run build

      - name: Deploy to Production
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          # Your deployment commands here
          npx vercel --prod --token=$DEPLOY_TOKEN

Useful Actions Tips

  • Cache dependencies with actions/cache or the built-in cache option in actions/setup-node to speed up workflows by 60-80%.
  • Use matrix strategies to test across multiple Node.js versions or operating systems simultaneously.
  • Set up required status checks in branch protection rules so PRs cannot be merged until CI passes.
  • Use environment secrets for API keys and tokens — never hardcode them in workflow files.

Branch Protection and Code Review Workflows

A well-configured GitHub repository enforces quality through branch protection rules.

Essential Protection Rules for Main Branch

  1. Require pull requests before merging. No direct pushes to main. Every change goes through a PR.
  2. Require at least one approving review. Someone else must look at the code before it lands.
  3. Require status checks to pass. CI must be green before merging is allowed.
  4. Require branches to be up to date. The PR branch must be current with main before merging.
  5. Require signed commits if your team values commit authenticity.

Code Review Best Practices

As a reviewer:

  • Review within 24 hours. Stale PRs block progress and demoralize contributors.
  • Comment on the "why," not just the "what." Instead of "this is wrong," explain why the current approach is problematic and suggest alternatives.
  • Use GitHub's suggestion feature for small changes. The author can accept them with one click.
  • Approve with comments when you have minor suggestions that do not block merging.

As an author:

  • Keep PRs small. A 50-line PR gets a thorough review. A 500-line PR gets a rubber stamp. Break large features into multiple PRs.
  • Write descriptive PR descriptions. Explain what changed, why, and how to test it.
  • Respond to all comments even if it is just "done" or "good point, fixed."

Handling Merge Conflicts Like a Pro

Merge conflicts are inevitable on collaborative projects. Here is how to handle them efficiently.

Prevention

The best conflict resolution is conflict prevention:

  • Pull from main frequently. The longer your branch diverges, the more conflicts you will face.
  • Communicate with your team about which files you are modifying.
  • Keep PRs small and focused to minimize overlap.

Resolution Strategy

When conflicts occur:

# Update your branch with latest main
git fetch origin
git merge origin/main
# Or use rebase for a linear history:
git rebase origin/main

For each conflicted file, Git marks the conflicts:

<<<<<<< HEAD
your changes
=======
their changes
>>>>>>> origin/main

Use your editor's merge conflict UI (VS Code has an excellent one) rather than editing these markers manually. VS Code shows "Accept Current Change," "Accept Incoming Change," "Accept Both," and "Compare Changes" buttons above each conflict.

The Nuclear Option

If a merge conflict becomes hopelessly tangled:

# Abort the merge and start fresh
git merge --abort

# Or if rebasing
git rebase --abort

Then consider a different strategy: rebase your branch onto main interactively, resolving conflicts commit by commit rather than all at once. Each individual commit's conflicts are usually smaller and easier to understand.


.gitignore Patterns

A well-crafted .gitignore prevents unnecessary files from entering your repository.

Essential Patterns

# Dependencies
node_modules/
vendor/
.venv/

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

# Environment files
.env
.env.local
.env.production

# IDE files
.vscode/settings.json
.idea/
*.swp
*.swo

# OS files
.DS_Store
Thumbs.db

# Logs
*.log
npm-debug.log*

# Testing
coverage/

# Secrets
*.pem
*.key
credentials.json

Useful Patterns

# Ignore all .log files in any directory
**/*.log

# Ignore everything in a directory except one file
temp/*
!temp/.gitkeep

# Ignore files with specific extensions recursively
**/*.min.js
**/*.min.css

Removing Already-Tracked Files

If you add a pattern to .gitignore but the files are already tracked:

# Remove from Git tracking without deleting the file
git rm --cached path/to/file

# Remove an entire directory from tracking
git rm -r --cached node_modules/

# Then commit the change
git commit -m "Remove tracked files that should be ignored"

Git Hooks: Automate Quality Checks

Git hooks are scripts that run automatically at specific points in the Git workflow. They live in .git/hooks/ but are better managed with tools like Husky for team-wide consistency.

Setting Up Husky

npm install -D husky lint-staged
npx husky init

Pre-Commit Hook

Run linting and formatting on staged files before each commit:

# .husky/pre-commit
npx lint-staged
// package.json
{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
    "*.{css,scss}": ["prettier --write"],
    "*.{json,md}": ["prettier --write"]
  }
}

Now every commit automatically lints and formats the changed files. No more "fix formatting" commits cluttering the history.

Commit Message Hook

Enforce conventional commit messages:

# .husky/commit-msg
npx commitlint --edit $1
// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional']
}

This rejects commits with messages like "fixed stuff" and requires structured messages like fix: resolve authentication timeout issue or feat: add user profile page.


Monorepo Management

Large projects often use monorepos — a single repository containing multiple packages or services. Git handles monorepos well with the right approach.

Sparse Checkout

If you only need a portion of a large monorepo:

git clone --no-checkout https://github.com/org/monorepo.git
cd monorepo
git sparse-checkout init --cone
git sparse-checkout set packages/my-service shared/utils
git checkout main

Only the specified directories are checked out, saving disk space and clone time.

Git Submodules vs Monorepo

Submodules embed one repository inside another. They sound appealing but create operational complexity — developers forget to update submodules, CI needs special configuration, and the workflow is generally confusing.

For most teams, a well-organized monorepo with tools like Turborepo, Nx, or Lerna is simpler and more productive than submodules. Keep the code together, manage dependencies with workspace features, and use build tools to handle incremental builds.


Commands Worth Memorizing

Here is a cheat sheet of powerful commands that I use regularly:

# See who last modified each line of a file
git blame path/to/file.ts

# View a file from a specific commit without checking it out
git show abc1234:src/config.ts

# Find all commits that modified a specific file
git log --follow -- path/to/file.ts

# Search commit messages for a keyword
git log --grep="authentication"

# Show diff statistics (files changed, insertions, deletions)
git diff --stat main..feature-branch

# Undo the last commit but keep the changes staged
git reset --soft HEAD~1

# Create an alias for common commands
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.st "status -sb"
git config --global alias.co "checkout"

# Clean up remote-tracking branches that no longer exist
git fetch --prune

# Show the reflog — a safety net for recovering lost commits
git reflog

The reflog deserves special attention. It records every position your HEAD has pointed to. Accidentally deleted a branch? Lost commits during a bad rebase? The reflog probably has them. It is your safety net.

# View the reflog
git reflog

# Recover a lost commit
git checkout -b recovered-branch abc1234

Building Better Habits

Mastering Git is not about memorizing commands — it is about building habits that keep your repository clean and your team productive.

Commit often, push deliberately. Make small, logical commits locally. Squash and clean them up with interactive rebase before pushing. Your local history is your scratch pad; your pushed history is your published work.

Write meaningful commit messages. Future-you will thank present-you when you are trying to understand why a change was made six months ago. "fix bug" tells you nothing. "fix: prevent duplicate payment processing on retry timeout" tells you everything.

Branch strategically. Use feature branches for new work. Keep main deployable at all times. Delete merged branches promptly — a repository with 200 stale branches is disorienting.

Automate everything you can. CI pipelines, pre-commit hooks, automated deployments — every manual step is an opportunity for human error. Let machines handle the repetitive work.

Git is one of those tools where deeper knowledge directly translates to faster, less frustrating development. Every hour spent learning these advanced features saves dozens of hours of confusion and manual work down the road. The investment is worth it.

Share

Priya Patel

Senior Tech Writer

Covers AI, machine learning, and emerging technologies. Previously at TechCrunch India.

Comments (0)

Leave a Comment

Related Articles