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.

The command that finds a bad commit in minutes
Picture a production branch that has gone sideways. A Friday afternoon deploy broke the checkout flow, three developers pushed conflicting hotfixes over the weekend, and on Monday nobody can work out which of 140 commits broke things.
Run git bisect and, because it's a binary search, about eight steps later you have the exact commit that introduced the bug. Testing by hand could take half a day; bisect takes minutes. It looks like wizardry, but it's just knowing your Git commands.
Every tutorial starts with git add, git commit and git push, maybe with git pull and git merge thrown in. You can get by on those for a long time, and most developers do. Getting by is a low bar, though. Senior developers untangle nasty merge conflicts in minutes that take juniors hours, and teams replace entire DevOps workflows with GitHub Actions in days.
This guide is about closing that gap. I'm assuming you already understand branching, merging and pull requests, so we'll go further than that.
Interactive rebase: rewriting history
Interactive rebase is probably the most powerful Git feature that most developers never touch. It lets you edit, reorder, squash, split and reword commits before anyone else sees them.
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
That's six commits, and half of them are noise. Before merging into main you want a clean history, which is what interactive rebase is for:
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
You end up with three clean commits instead of six messy ones. Squash folded the forgotten test file into the validation commit, and drop removed the work-in-progress and cleanup commits completely.
Available commands
| Command | What It Does |
|---|---|
pick | Keep the commit as-is |
reword | Keep the commit but edit the message |
edit | Pause at this commit for amendments |
squash | Merge into the previous commit, combine messages |
fixup | Merge into the previous commit, discard this message |
drop | Remove the commit entirely |
The one rule
Never rebase commits that have been pushed to a shared branch. Interactive rebase rewrites commit hashes, so if other developers built on those commits, you leave them with divergent histories and a real headache. Keep it to your local feature branches before merging. I've seen teams lose hours because someone rebased a shared branch.
Cherry-picking: moving single commits
Cherry-picking applies one specific commit from one branch to another. A merge brings over every commit; cherry-pick takes only the ones you name.
# 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
Say you're on the release-2.0 branch and find a critical bug. You fix it on main with commit fix-critical-auth-bypass. The release branch needs that fix right now, but merging all of main would also drag in untested new features. So you cherry-pick that one commit:
git checkout release-2.0
git cherry-pick fix-critical-auth-bypass
The bug fix is now on both branches, and nothing else came along with it.
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 it still surprises me how few developers know it. git bisect uses binary search to find exactly which commit introduced a bug.
Suppose your application worked fine two weeks ago and it's broken now, with 100 commits in between. Testing each one by hand would take hours. Bisect gets there 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 have a test script that returns 0 for pass and non-zero for fail, you can automate the whole thing:
git bisect start
git bisect bad HEAD
git bisect good abc1234
git bisect run npm test
Git runs the test at every step and finds the breaking commit with nobody touching the keyboard, which makes it great for regression testing. I think every team should talk about adding this to their debugging workflow. It looks niche until the first time you use it, and after that you can't imagine debugging without it.
Stashing strategies
git stash saves your uncommitted changes for later. Most developers know git stash and git stash pop, and there's a fair bit 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
You can create a new branch from a stash, which is handy when you realize the stashed work deserves its own branch:
git stash branch new-feature-branch stash@{0}
Git creates a new branch from the commit where you originally stashed, applies the stash and drops it from the stash list, all in one command.
Git worktrees: several branches at once
Sometimes you need to check something on another branch without stashing your current work or committing half-finished changes. Worktrees handle that by letting you check out multiple branches at the same time, each in its own directory.
# 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
Worktrees are great for code reviews. Instead of stashing your work and checking out the PR branch, create a worktree, review the code in a separate VS Code window and delete the worktree when you're done, while your main work stays exactly where you left it. Our best VS Code extensions for 2026 list has a few that make this workflow even smoother.
# List all active worktrees
git worktree list
# Prune stale worktree references
git worktree prune
Worktrees are surprisingly little known, which is a shame, because the problem they solve is a really annoying one.
GitHub Actions: CI/CD without the headache
GitHub Actions automates workflows that run on repository events such as pushes, pull requests, schedules and manual triggers. If you still run tests by hand before merging PRs, this section could save you a lot of time.
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
Now every push to main and every pull request runs linting, tests and builds on its own. If any step fails, the PR gets a red X, and "it works on my machine" stops being an excuse.
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/cacheor the built-in cache option inactions/setup-node; it speeds up workflows by 60-80%. - Use matrix strategies to test across several Node.js versions or operating systems at once.
- Set up required status checks in branch protection rules so a PR can't be merged until CI passes.
- Keep API keys and tokens in environment secrets, and never hardcode them in workflow files.
Branch protection and code review workflows
A well-configured GitHub repository enforces quality with branch protection rules. Without them it's the Wild West.
Protection rules for the main branch
- Require pull requests before merging, so nobody pushes straight to main and every change goes through a PR.
- Require at least one approving review, so someone else looks at the code before it lands.
- Require status checks to pass. CI has to be green before a merge is allowed.
- Require branches to be up to date with main before merging.
- Require signed commits if your team cares about commit authenticity.
Code review best practices
As a reviewer:
- Review within 24 hours. Stale PRs block progress and wear contributors down.
- Explain the "why" as well as the "what." Instead of "this is wrong," say why the current approach causes problems and suggest an alternative.
- Use GitHub's suggestion feature for small changes, since the author can accept them with one click.
- Approve with comments when your suggestions are minor and shouldn't block the merge.
As an author:
- Keep PRs small. A 50-line PR gets a thorough review, while a 500-line PR gets rubber-stamped. Split large features into several PRs.
- Write descriptive PR descriptions that cover what changed, why, and how to test it.
- Respond to every comment, even if the reply is just "done" or "good point, fixed."
I suspect a lot of teams skip code review because it feels slow. In my experience it saves time, because it catches bugs that would cost far more to fix later.
Handling merge conflicts
On a shared project, merge conflicts will happen. This is how I deal with them without losing my mind.
Prevention
The easiest conflict to resolve is the one you never get:
- Pull from main often. The longer your branch drifts, the more conflicts you'll hit.
- Tell your team which files you're working on.
- Keep PRs small and focused so they overlap less.
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) instead of editing these markers by hand. VS Code puts "Accept Current Change," "Accept Incoming Change," "Accept Both," and "Compare Changes" buttons above each conflict, which beats squinting at 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 try a different strategy: rebase your branch onto main interactively and resolve the conflicts one commit at a time. Each commit's conflicts tend to be smaller and easier to follow. For long-lived feature branches I'd argue this is always the better approach, although it takes a bit more patience.
.gitignore patterns
A good .gitignore keeps unnecessary files out of 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: automated quality checks
Git hooks are scripts that run automatically at specific points in the Git workflow. They live in .git/hooks/, but for consistency across a team they're better managed with a tool like Husky.
Setting up Husky
npm install -D husky lint-staged
npx husky init
Pre-commit hook
This runs 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"]
}
}
Every commit now lints and formats the changed files on its own, so "fix formatting" commits stop cluttering the history. I think this alone saves teams hours per week, probably more if some developers forget to run the linter before pushing.
Commit message hook
To enforce conventional commit messages:
# .husky/commit-msg
npx commitlint --edit $1
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional']
}
This rejects messages like "fixed stuff" and requires structured ones like fix: resolve authentication timeout issue or feat: add user profile page. Your future self will be grateful when scrolling through git log.
Monorepo management
Large projects often use a monorepo, one repository holding multiple packages or services. Git copes with monorepos fine if you approach them right, though without proper tooling they get unwieldy.
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 directories you list get checked out, which saves disk space and clone time. On a huge monorepo with hundreds of packages, that can take minutes off the clone.
Git submodules vs monorepo
Submodules embed one repository inside another. They sound appealing and then add operational pain: developers forget to update them, CI needs special configuration, and the workflow is confusing in general.
For most teams, a well-organized monorepo with Turborepo, Nx or Lerna is simpler and more productive than submodules. Keep the code together, manage dependencies with workspace features and let the build tools handle incremental builds. I've yet to meet a team that moved from a monorepo to submodules and was happy about it, while the move in the other direction happens all the time.
Commands worth memorizing
A cheat sheet of the 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 a closer look. It records every position your HEAD has pointed to, so if you deleted a branch by accident or lost commits in a bad rebase, the reflog probably still has them.
# 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, which is a shame, because it would spare them a lot of panic during botched rebases. Bookmark this section for the day you need it.
Building better habits
Getting good at Git has less to do with memorizing commands than with habits that keep your repository clean and your team productive. These are the ones that stuck with me.
Commit often and push deliberately. Make small, logical commits locally, then squash and tidy them with interactive rebase before you push. Local history is your scratch pad, and pushed history is your published work.
Write meaningful commit messages. Six months from now, when you're trying to work out why a change was made, you'll be glad you did. "fix bug" tells you nothing, while "fix: prevent duplicate payment processing on retry timeout" tells you everything.
Branch with a plan. Use feature branches for new work, keep main deployable at all times, and delete merged branches promptly, because a repository with 200 stale branches confuses everyone.
Automate whatever you can. CI pipelines, pre-commit hooks and automated deployments all remove manual steps, and each manual step is a chance for human error. If you're setting up a development environment from scratch, our Linux for developers guide pairs well with this Git workflow.
Git muscle memory only comes with practice. You won't remember every command from reading this guide, and that's fine, as long as you know these tools exist and can reach for them when needed. It clicks the first time git bisect pinpoints a bug in seconds, or the first time you rescue a lost branch from the reflog. From then on the commands feel less like trivia and more like part of how you think about code.
Keep practicing, and experiment on throwaway branches where mistakes don't matter. Then the next time someone on your team asks "does anyone know how to undo a bad rebase?", you'll be the one who knows.
Anurag Sinha
Founder & Editor
Founder and editor of Tech Tips India. Writes tech guides, buying guides and explainers for Indian readers, with rupee pricing and local availability. Product roundups are researched from manufacturer specs and published reviews, not lab testing.
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

API Design: REST and GraphQL Patterns That Scale
API design guide covering REST, GraphQL, tRPC, authentication, rate limiting, error handling, and testing with Node.js examples.

DSA Roadmap for Placements: What Matters in 2026
Practical DSA roadmap for campus placements in India: topic priorities, platform choices, company patterns, and time management.

AWS vs GCP vs Azure 2026: Honest Dev Comparison
A practical comparison of AWS, Google Cloud, and Azure for Indian developers covering compute, serverless, databases, pricing, free tiers, and career advice.