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
17 min read
Git and GitHub Mastery: Beyond the Basics

Git Literally Saved My Week Last Month

So there I was, staring at a production branch that had gone sideways. A Friday afternoon deploy had broken checkout flows, three different developers had pushed conflicting hotfixes over the weekend, and Monday morning felt like walking into a crime scene. Nobody could figure out which commit broke things. Manual testing? With 140 commits over three days? Not happening.

I ran git bisect. Seven steps. That's it — seven steps and I had the exact commit that introduced the bug. What would've taken the team half a day of manually reverting and testing took me about twelve minutes. My manager thought I was some kind of wizard. Nope. Just knew my Git commands.

Here's the thing most people don't talk about. Every tutorial starts with git add, git commit, git 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" aren't the same thing. I've watched senior developers untangle nightmarish merge conflicts in minutes that would take juniors hours. I've seen teams where GitHub Actions replaced entire DevOps workflows overnight.

Bridging that gap is what this guide's about. I'm assuming you already understand branching, merging, and pull requests. We're going deeper.


Interactive Rebase: Rewriting History Like a Pro

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

When to Use It

You've 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

Six commits. Half of them are noise. Before merging into main, you want a clean history. Interactive rebase to the rescue:

git rebase -i HEAD~6

Your editor opens 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

Three clean commits instead of six messy ones. Squash merged the forgotten test file into the validation commit. Drop removed the work-in-progress and cleanup commits entirely.

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

One 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. Seriously. I've seen teams lose hours because someone rebased a shared branch. Don't be that person.


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. Nothing more.

# 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're 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 don't 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. Bug fix's 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

My favorite Git command. I'm 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 it's broken now. There are 100 commits in between. Testing each one manually? That'd 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 whether 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've got 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. Absurdly powerful for regression testing. I think every team should have a conversation about incorporating this into their debugging workflow, because it's one of those tools that seems niche until you actually use it — then you wonder how you ever managed without it.


Stashing Strategies

git stash saves your uncommitted changes temporarily. Most developers know git stash and git stash pop. But there's 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 — super useful when you realize your stashed work deserves its own branch:

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

What happens here is pretty slick. Git creates a new branch from the commit where you originally stashed, applies the stash, and drops it from the stash list. Clean.


Git Worktrees: Multiple Branches, Simultaneously

Ever needed to check something on another branch but didn't 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 I'm done — check out our best VS Code extensions for 2026 to supercharge this workflow. My main work stays completely undisturbed.

# List all active worktrees
git worktree list

# Prune stale worktree references
git worktree prune

Not sure if worktrees are well-known enough. Most devs I've talked to have never heard of them, which seems like a shame because they solve a really annoying problem.


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're still running tests manually before merging PRs, this section might 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 gets 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 can't 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. Without them, it's the Wild West.

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's 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've got minor suggestions that don't block merging.

As an author:

  • Keep PRs small. A 50-line PR gets a thorough review. A 500-line PR? 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's just "done" or "good point, fixed."

I suspect a lot of teams skip code review because they feel it slows them down. It doesn't. It catches bugs that would've cost way more time later.


Handling Merge Conflicts Like a Pro

Merge conflicts are inevitable on collaborative projects. Here's how to handle them without losing your mind.

Prevention

Best conflict resolution is conflict prevention:

  • Pull from main frequently. Longer your branch diverges, the more conflicts you'll face.
  • Communicate with your team about which files you're 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. Way easier than eyeballing angle brackets.

When Things Go Really Wrong

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. Arguably, this is always the better approach for long-lived feature branches, but it takes a bit more patience.


.gitignore Patterns

A well-crafted .gitignore prevents unnecessary files from entering your repository. You'd be surprised how many repos I've seen with node_modules committed. Yikes.

Patterns You Need

# 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. I think this alone saves teams hours per week — probably more if you've got developers who forget to run the linter before pushing.

Commit Message Hook

Enforce conventional commit messages:

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

Rejects commits with messages like "fixed stuff" and requires structured messages like fix: resolve authentication timeout issue or feat: add user profile page. Your future self will thank you when scanning through git log.


Monorepo Management

Large projects often use monorepos — a single repository containing multiple packages or services. Git handles monorepos well with the right approach, though it tends to get unwieldy without proper tooling.

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 get checked out, saving disk space and clone time. For massive monorepos with hundreds of packages, this can shave minutes off your clone.

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's 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. I've yet to meet a team that switched from monorepo to submodules and was happy about it. Going the other direction? Happens all the time.


Commands Worth Memorizing

Here's a cheat sheet of powerful commands I reach for 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's your safety net. Seriously.

# View the reflog
git reflog

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

I suspect most developers don't know the reflog exists, and that's a shame because it would save them from panicking during botched rebases. Bookmark this section. Come back to it when you need it.


Building Better Habits

Mastering Git isn't about memorizing commands. It's about building habits that keep your repository clean and your team productive. Let me share the ones that've stuck with me.

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're 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 for everyone.

Automate everything you can. CI pipelines, pre-commit hooks, automated deployments — every manual step's an opportunity for human error. If you're setting up a development environment from scratch, our Linux for developers guide pairs well with this Git workflow. Let machines handle the repetitive work.

Here's what I've noticed after years of using Git: muscle memory comes with practice. You won't remember every command from reading this guide, and that's fine. What matters is knowing these tools exist so you can reach for them when you need them. The first time you run git bisect and watch it pinpoint a bug in seconds, or the first time you rescue a lost branch from the reflog — that's when it clicks. That's when these commands stop being trivia and start being part of how you think about code.

Keep practicing. Keep experimenting on throwaway branches where mistakes don't matter. And the next time someone on your team asks "does anyone know how to undo a bad rebase?" — you'll be the one who knows.

Share

Priya Patel

Senior Tech Writer

AI and machine learning specialist with 6 years covering emerging technologies. Previously a senior tech correspondent at TechCrunch India, she now writes in-depth analyses of AI tools, LLM developments, and their real-world applications for Indian businesses.

Stay Ahead in Tech

Get the latest tech news, tutorials, and reviews delivered straight to your inbox every week.

No spam ever. Unsubscribe anytime.

Comments (0)

Leave a Comment

All comments are moderated before appearing. Please be respectful and follow our community guidelines.

Related Articles