Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Git & GitHub CLI: From TFS/Azure DevOps to GitHub

For .NET engineers who know: TFS, Azure DevOps Repos, Visual Studio Git integration, Azure DevOps pull requests You’ll learn: How to operate Git and GitHub entirely from the terminal, including our team’s branch strategy, commit format, and code review workflow Time: 15-20 minutes


The .NET Way (What You Already Know)

In Azure DevOps, your workflow probably looked like this:

  • You cloned a repo through Visual Studio or the Azure DevOps web UI
  • Branches were created through a GUI with a ticket number prefix
  • Pull Requests were reviewed in the Azure DevOps web portal
  • Pipelines ran automatically on PR or merge
  • Work Items were linked to commits and PRs through the UI

If you used TFS, changesets were the unit of work. Shelvesets held work-in-progress. Branching was expensive and discouraged. Merging was done through a GUI and felt dangerous.

Git fundamentally changed this — branching is free, history is local, and the terminal is the canonical interface. Most experienced JS engineers never open a GUI for Git.


The GitHub Way

Core Concepts Mapped

Azure DevOps / TFS ConceptGit / GitHub Equivalent
RepositoryRepository (same)
ChangesetCommit
Shelvesetgit stash
Branch policyBranch protection rules
Pull RequestPull Request (same concept, different UI)
Work Item linkIssue reference in commit/PR body
Build pipelineGitHub Actions workflow
Service connectionGitHub Secret
Variable groupRepository/Organization secrets
tf getgit pull --rebase
tf checkingit commit + git push
Code review in ADO portalgh pr review or GitHub web

Our Git Configuration Baseline

Before anything else, configure Git properly:

git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global core.editor "code --wait"
git config --global pull.rebase true
git config --global init.defaultBranch main
git config --global rebase.autoStash true

The pull.rebase true setting means git pull automatically rebases instead of creating a merge commit. This keeps history linear — the same reason you’d use “Rebase and fast-forward” in Azure DevOps.


Essential Commands You Need Daily

Checking state:

git status                    # What's changed?
git diff                      # Unstaged changes
git diff --staged             # Staged changes (what will commit)
git log --oneline --graph     # Visual history
git log --oneline -10         # Last 10 commits

Branching:

git checkout -b feature/my-feature    # Create and switch
git switch -c feature/my-feature      # Modern syntax (same result)
git branch -a                         # All branches including remote
git branch -d feature/done            # Delete merged branch
git branch -D feature/abandoned       # Force delete

Staging and committing:

git add src/components/Button.tsx     # Stage specific file
git add -p                            # Interactive staging (hunk by hunk)
git commit -m "feat(auth): add JWT refresh logic"
git commit --amend                    # Fix last commit message or add files

Synchronizing:

git fetch origin                      # Download remote changes, don't apply
git pull                              # Fetch + rebase (with our config)
git push origin feature/my-feature    # Push branch
git push -u origin HEAD               # Push current branch, set upstream

Git Stash: The Shelveset Equivalent

Stash saves your work-in-progress without committing:

git stash                             # Stash all tracked changes
git stash push -m "WIP: auth refactor"  # Named stash
git stash list                        # See all stashes
git stash pop                         # Apply most recent stash, then delete it
git stash apply stash@{2}             # Apply specific stash, keep it
git stash drop stash@{2}              # Delete specific stash
git stash branch feature/new stash@{0}  # Create branch from stash

When to use it: You’re mid-feature and need to pull, review something, or hot-fix another branch. Stash your work, switch contexts, come back.


Rebase: The Clean History Tool

Rebase rewrites commit history by replaying your commits on top of another branch. Think of it as “pretend I started this work from a later point.”

# Update your feature branch with latest main
git fetch origin
git rebase origin/main

# If conflicts occur:
git rebase --continue    # After resolving
git rebase --abort       # Cancel and go back to pre-rebase state

# Interactive rebase: rewrite the last 3 commits
git rebase -i HEAD~3

Interactive rebase (-i) opens an editor with your commits listed:

pick a1b2c3 feat: add user model
pick d4e5f6 fix typo
pick g7h8i9 feat: add user validation

# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# s, squash = meld into previous commit
# f, fixup = meld into previous commit, discard log message
# d, drop = remove commit

Common uses:

  • squash multiple WIP commits before PR
  • reword to fix a commit message
  • drop to remove a commit entirely
  • fixup to silently fold a typo-fix into the previous commit

Cherry-Pick: Bring One Commit Anywhere

Cherry-pick applies a specific commit to a different branch — like cherry-picking a single changeset.

git log --oneline feature/auth        # Find the commit hash
# a1b2c3 feat: add password reset endpoint

git checkout main
git cherry-pick a1b2c3               # Apply that one commit here
git cherry-pick a1b2c3..g7h8i9       # Range of commits
git cherry-pick -n a1b2c3            # Stage but don't commit (--no-commit)

Use case: A hotfix was committed to a feature branch and you need it on main immediately without merging the whole feature branch.


Bisect: Binary Search Through History

git bisect finds the commit that introduced a bug using binary search. You mark commits as “good” or “bad” and Git narrows it down.

git bisect start
git bisect bad                        # Current commit is broken
git bisect good v1.2.0                # This tag worked fine

# Git checks out a middle commit. Test it, then:
git bisect good                       # This one works
# or
git bisect bad                        # This one doesn't

# Git keeps narrowing until it finds the culprit.
# When done:
git bisect reset                      # Return to original HEAD

You can also automate it with a test script:

git bisect run npm test               # Runs test suite at each commit

Branch Naming Convention

Our branches follow this pattern:

<type>/<ticket-or-short-description>

feat/user-auth
fix/login-redirect-loop
chore/update-dependencies
docs/api-endpoints
refactor/extract-auth-middleware
test/user-service-coverage

Types mirror conventional commit types (described below). The ticket ID goes first if there is one:

feat/GH-142-user-auth
fix/GH-89-login-redirect

Conventional Commits

Our commit format follows the Conventional Commits specification:

<type>(<scope>): <short description>

[optional body]

[optional footer(s)]

Types:

TypeWhen to Use
featNew feature
fixBug fix
choreMaintenance, dependency updates
docsDocumentation only
refactorCode change that neither fixes a bug nor adds a feature
testAdding or fixing tests
styleFormatting, whitespace (no logic changes)
perfPerformance improvement
ciCI/CD configuration
buildBuild system changes

Examples:

feat(auth): add JWT refresh token rotation

fix(api): handle null response from payment gateway

chore(deps): upgrade Prisma to 5.12.0

refactor(user-service): extract email validation to shared util

feat!: drop support for Node 18

BREAKING CHANGE: minimum Node version is now 20

The ! suffix and BREAKING CHANGE footer signal a breaking change — equivalent to a major version bump in semantic versioning.


The GitHub CLI (gh)

gh is the official GitHub CLI. Install it:

# macOS
brew install gh

# Authenticate
gh auth login

Pull Requests:

gh pr create                          # Interactive PR creation
gh pr create --title "feat: add auth" --body "Closes #42"
gh pr create --draft                  # Draft PR
gh pr list                            # PRs in current repo
gh pr view 123                        # View PR #123
gh pr checkout 123                    # Check out PR branch locally
gh pr review 123 --approve            # Approve
gh pr review 123 --request-changes --body "See inline comments"
gh pr merge 123 --squash              # Merge with squash
gh pr merge 123 --rebase              # Merge with rebase
gh pr close 123                       # Close without merging

Issues:

gh issue list                         # All open issues
gh issue view 42                      # View issue #42
gh issue create --title "Bug: login fails" --body "Steps: ..."
gh issue close 42
gh issue comment 42 --body "Fixed in #57"

Repositories:

gh repo clone org/repo                # Clone repo
gh repo view                          # View current repo in browser
gh repo fork                          # Fork current repo

Checks and Actions:

gh run list                           # Recent workflow runs
gh run view 12345678                  # View specific run
gh run watch                          # Watch current run live
gh workflow list                      # All workflows
gh workflow run deploy.yml            # Manually trigger workflow

Searching across PRs:

gh pr list --search "is:open assignee:@me"
gh pr list --search "label:needs-review"
gh issue list --search "milestone:v2.0"

Our Code Review Workflow

  1. Push your branch and open a PR:
git push -u origin HEAD
gh pr create --title "feat(auth): add password reset" --body "$(cat <<'EOF'
## Summary
- Adds /auth/reset-password endpoint
- Sends reset email via SendGrid
- Token expires in 1 hour

## Testing
- [ ] Tested happy path locally
- [ ] Tested expired token case
- [ ] Added unit tests for token generation

Closes #89
EOF
)"
  1. Request reviews:
gh pr edit --add-reviewer alice,bob
  1. Respond to review comments — push additional commits, then:
gh pr review --comment --body "All comments addressed in latest push"
  1. Merge strategy: we use squash merge for feature branches to keep main history clean. Hotfixes use rebase merge.
gh pr merge --squash --delete-branch

.gitignore for Node.js

Create a comprehensive .gitignore at the project root:

# Dependencies
node_modules/
.pnp
.pnp.js

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

# TypeScript
*.tsbuildinfo

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

# Logs
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
*.log

# Editor
.vscode/
!.vscode/extensions.json
!.vscode/settings.json
.idea/
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Testing
coverage/
.nyc_output/

# Turbo
.turbo/

# Misc
.cache/
*.local

PR Template

Create .github/pull_request_template.md in your repo:

## Summary
<!-- What does this PR do? Why? -->

## Changes
-

## Testing
- [ ] Unit tests pass (`npm test`)
- [ ] Type check passes (`npm run typecheck`)
- [ ] Lint passes (`npm run lint`)
- [ ] Tested in browser / local environment

## Screenshots
<!-- If UI changes, before/after screenshots -->

## Related Issues
Closes #

Trunk-Based Development vs GitFlow

GitFlow (what many Azure DevOps teams use):

  • main / develop / feature/* / release/* / hotfix/*
  • Long-lived develop branch
  • Formal release branches
  • Works well for scheduled releases with long QA cycles

Trunk-Based Development (what we use):

  • Only main is long-lived
  • Feature branches are short-lived (1-3 days max)
  • Feature flags control incomplete work in production
  • Merges to main trigger deployment

We use trunk-based development because it:

  • Eliminates merge hell from long-lived branches
  • Forces smaller, reviewable PRs
  • Keeps the deployment pipeline always green
  • Works naturally with feature flags
# Start work
git switch -c feat/GH-142-user-auth

# Commit often — multiple small commits while working
git commit -m "feat(auth): add user model"
git commit -m "feat(auth): add login endpoint"
git commit -m "test(auth): add login unit tests"

# Before PR: squash into meaningful commits
git rebase -i origin/main

# Push and PR
git push -u origin HEAD
gh pr create

GitHub Actions Basics

Actions are covered in detail in article 6.2, but for Git context: a basic workflow that runs on every PR looks like this:

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test

The branch protection rules in GitHub settings can require this workflow to pass before a PR can be merged — equivalent to Azure DevOps branch policies requiring a passing build.


Key Differences

ConceptAzure DevOpsGitHub
Default merge strategyMerge commitConfigurable per repo (we use Squash)
PR checksBuild validation policiesRequired status checks
Code ownersCode reviewer policiesCODEOWNERS file
Branch policiesBranch policies UIBranch protection rules
Personal access tokensPATs in ADOPATs or GitHub Apps
CLI toolingaz devops / tfgh
NotificationsADO notification settingsGitHub notification settings
WikiAzure DevOps WikiGitHub Wiki or repo docs

Gotchas for .NET Engineers

Gotcha 1: git pull creates ugly merge commits by default. Out of the box, git pull does fetch + merge, creating a merge commit even when you’re just syncing with the remote. This clutters history. Configure pull.rebase true globally (shown above) or use git pull --rebase explicitly every time.

Gotcha 2: Rebase rewrites history — never rebase shared branches. git rebase replaces commit hashes. If you rebase a branch that others have pulled, their history diverges and the result is a painful force-push situation. Only rebase branches that are yours alone. Never rebase main.

Gotcha 3: git commit --amend after push requires force push. If you amend a commit that’s already on the remote, you need git push --force-with-lease (not --force). --force-with-lease checks that no one else has pushed to the branch since you last fetched — safer than a blind force push. Never force push to main.

Gotcha 4: Squash merging loses commit attribution in history. When you squash-merge a PR, all commits become one commit authored by the PR author. If you care about blame granularity per commit, use rebase merge. For feature work, squash is fine and keeps main history clean.

Gotcha 5: git add . includes files you don’t want. Unlike Visual Studio which shows you a diff before checkin, git add . stages everything including generated files, temp files, or secrets. Use git add -p for interactive staging, or be explicit with file paths. Always review git status before committing.

Gotcha 6: Conventional commits are not enforced by default. The format is a convention. Nobody stops you from committing "fixed stuff". Teams enforce it with a commitlint pre-commit hook. If your project uses one, git commit will fail on non-conforming messages — read the error before trying to bypass it.


Hands-On Exercise

Set up a local repo with our full workflow:

# 1. Create and initialize a repo
mkdir git-practice && cd git-practice
git init
git commit --allow-empty -m "chore: initial commit"

# 2. Create .gitignore
cat > .gitignore << 'EOF'
node_modules/
dist/
.env
EOF
git add .gitignore
git commit -m "chore: add gitignore"

# 3. Simulate feature work
git switch -c feat/add-greeting
echo 'export const greet = (name: string) => `Hello, ${name}`;' > greet.ts
git add greet.ts
git commit -m "feat: add greet function"

echo 'export const farewell = (name: string) => `Goodbye, ${name}`;' >> greet.ts
git add greet.ts
git commit -m "feat: add farewell function"

# 4. Interactive rebase to squash both into one
git rebase -i HEAD~2
# Change second 'pick' to 'fixup', save

# 5. Verify clean history
git log --oneline

# 6. Practice stash
echo "work in progress" >> greet.ts
git stash push -m "WIP: adding more functions"
git stash list
git stash pop

# 7. Practice bisect
git bisect start
git bisect good HEAD~1
git bisect bad HEAD
git bisect reset

If you have gh authenticated, push to GitHub and practice PR creation:

gh repo create git-practice --private --source=. --push
gh pr create --title "feat: add greeting functions" --body "Practice PR"
gh pr view --web

Quick Reference

# Daily workflow
git switch -c feat/my-feature        # New branch
git add -p                           # Stage interactively
git commit -m "feat(scope): message" # Conventional commit
git push -u origin HEAD              # Push + set upstream
gh pr create                         # Open PR

# Keeping branch current
git fetch origin
git rebase origin/main

# Clean up history before PR
git rebase -i HEAD~N                 # Squash N commits

# Stash
git stash push -m "description"
git stash pop

# Cherry-pick
git cherry-pick <hash>

# Find regression
git bisect start
git bisect bad
git bisect good <tag-or-hash>
git bisect reset

# GitHub CLI
gh pr list
gh pr checkout <number>
gh pr review <number> --approve
gh pr merge --squash --delete-branch
gh run list
gh issue list

Further Reading