Back to Blog
GitDevOpsTeam LeadershipAzure DevOpsMigration

Migrating from TFS to Git: A Team Lead's Playbook

Umut Korkmaz2025-07-107 min

Last year, I led the migration of our team's source control from Team Foundation Version Control (TFVC/TFS) to Git on Azure DevOps. This was not just a technical migration — it was a cultural shift for a team that had been using centralized version control for over a decade. Here is the playbook I followed, the pitfalls I hit, and what I would do differently.

Why We Migrated

TFS worked. Let me be clear about that. For years, it served our team fine. But as the internal platform grew from a single legacy application to a modern stack with React, .NET 10, and React Native, the limitations became painful:

  1. Branch-per-feature was impossible. In TFVC, branches are heavyweight copies. Creating a branch for every feature or bug fix was simply not done. Developers worked on a single branch and hoped for the best.
  2. No pull request workflow. Code reviews happened over someone's shoulder or not at all. There was no structured way to review changes before they reached the main codebase.
  3. Offline work was impossible. TFVC requires a server connection for almost everything. Developers on the VPN during weekends or commuting on laptops could not commit.
  4. CI/CD integration was limited. Modern CI/CD pipelines are built around Git concepts — branches, tags, webhooks. TFVC support in Azure Pipelines felt like an afterthought.

Phase 1: Preparation (2 Weeks)

Training First, Migration Second

The biggest mistake I see teams make is migrating the repository first and training people later. I did it the other way around. Before touching a single line of source control configuration, I spent two weeks training the team:

  • Day 1-2: Git fundamentals — commits, branches, merges, the staging area. Not through slides, but hands-on with a practice repository.
  • Day 3-4: The branching strategy we would use (Git Flow adapted for our release cycle).
  • Day 5: Pull requests, code reviews, and approval workflows in Azure DevOps.
  • Week 2: Practice sprint where everyone used Git on a non-critical project.

This upfront investment saved us weeks of confusion later.

Repository Audit

Before migrating, I audited our TFS repositories:

TFS Workspace
├── $/LegacyApp/Main          # 2.3 GB (including binaries!)
├── $/LegacyApp/Dev           # Branch from Main
├── $/LegacyApp/Release       # Branch from Main
├── $/LegacyApp/Archive       # 5 years of archived releases
└── $/Shared/Components      # Shared libraries

Problems identified:

  • Large binaries in source control: compiled DLLs, NuGet packages, even ZIP files of third-party libraries. Git would choke on these.
  • 5 years of history: Over 15,000 changesets. Migrating all history would create a massive repository.
  • Shared components: Referenced by multiple projects, needed to become a NuGet package.

Phase 2: Clean Up Before Migration

Binary Removal

I created a .gitignore template and identified every binary that needed to be removed:

gitignore
# Build outputs
[Bb]in/
[Oo]bj/
*.dll
*.exe
*.pdb

# NuGet packages (restored, not committed)
packages/
*.nupkg

# Third-party libraries (use NuGet/npm instead)
lib/external/

# IDE files
.vs/
*.user
*.suo

Shared components that were previously committed as DLLs were published to our private Azure Artifacts NuGet feed. This alone reduced the repository size from 2.3 GB to 180 MB.

History Decision

We debated whether to migrate full history. I decided on a pragmatic approach: migrate the last 2 years of history, archive the rest. The reasoning:

  • Developers rarely look at changes older than a few months
  • The old history contained paths to files that no longer exist
  • A clean starting point is more valuable than perfect history

For the archive, I used git-tfs to create a read-only Git mirror of the full TFS history, accessible but separate from the active repository.

Phase 3: The Migration

I used git-tfs for the actual migration:

bash
# Clone from TFS with history from the last 2 years
git tfs clone https://tfs.company.com/collection $/LegacyApp/Main   --from=CS12000   --ignore-regex="packages/|bin/|obj/"   legacy-app-git

cd legacy-app-git

# Clean up TFS metadata
git filter-branch --msg-filter   'sed "s/^git-tfs-id:.*$//"' -- --all

# Add .gitignore
cp ../templates/.gitignore .
git add .gitignore
git commit -m "Add .gitignore for Git workflow"

# Push to Azure DevOps Git repository
git remote add origin https://dev.azure.com/org/InternalPlatform/_git/InternalPlatform
git push -u origin main

The migration itself took about 4 hours for 2 years of history.

Phase 4: Branch Strategy

We adopted a simplified Git Flow:

main ──────────────────────────────── Production
  └── develop ─────────────────────── Integration
       ├── feature/DIGI-123-new-form  Feature branches
       ├── feature/DIGI-456-api-fix
       └── bugfix/DIGI-789-null-check

Branch policies in Azure DevOps enforced the workflow:

yaml
# Branch policy for 'main'
- Require pull request: true
- Minimum reviewers: 2
- Require linked work item: true
- Build validation: CI pipeline must pass
- Comment resolution: All comments must be resolved

# Branch policy for 'develop'
- Require pull request: true
- Minimum reviewers: 1
- Build validation: CI pipeline must pass

Phase 5: The Cutover

The cutover was done on a Friday evening (yes, I know — but it was the only window with minimal active development):

  1. 5:00 PM: Announced TFS freeze — no more check-ins
  2. 5:15 PM: Ran final git tfs fetch to capture any last-minute changes
  3. 5:30 PM: Pushed final state to Azure DevOps Git
  4. 6:00 PM: Verified all developers could clone and build
  5. 6:30 PM: Made TFS repository read-only
  6. Monday morning: Team started working with Git

I had two developers on standby over the weekend for any issues. Only one came up — a developer had uncommitted changes in TFS that needed to be manually applied to a Git branch.

What Went Wrong

The Merge Confusion

Despite training, the first two weeks had frequent merge conflicts. In TFS, conflicts are rare because everyone works on the same branch and checks are sequential. In Git, feature branches diverge and merging is a regular activity. We added a daily "merge from develop" habit to keep feature branches short-lived and close to the integration branch.

The Large File

One developer committed a 200 MB test database file. In TFS, this would have been annoying but fixable with a simple delete. In Git, that file is in the history forever. I had to use git filter-repo to remove it, which meant everyone had to re-clone. After that, we set up pre-commit hooks and repository size alerts:

bash
#!/bin/sh
# .git/hooks/pre-commit - Reject files over 10MB
MAX_SIZE=10485760  # 10MB in bytes

for file in $(git diff --cached --name-only); do
  size=$(wc -c < "$file" 2>/dev/null || echo 0)
  if [ "$size" -gt "$MAX_SIZE" ]; then
    echo "ERROR: $file is $(($size / 1048576))MB. Max allowed is 10MB."
    echo "Use Azure Artifacts or file storage instead."
    exit 1
  fi
done

Results After 6 Months

  • Pull request adoption: 100% of changes go through PRs. Code review is now part of the culture.
  • Branch count: Average of 8-12 active feature branches at any time. Each lives for 2-5 days.
  • Deployment frequency: From monthly releases to weekly, enabled by better branch management.
  • Merge conflicts: After the initial learning curve, rare. Short-lived branches are the key.
  • Developer satisfaction: Every single team member prefers Git. Not one person has asked to go back.

Advice for Team Leads

  1. Train before you migrate. Two weeks of training saves two months of confusion.
  2. Clean up binaries before migration. Git is not designed for large binaries. Move them to artifact feeds.
  3. Do not migrate all history. Two years is enough. Archive the rest separately.
  4. Enforce branch policies from day one. It is easier to start strict and relax than the opposite.
  5. Expect merge confusion. It is the biggest culture shock. Be patient and pair with developers through their first few merges.
  6. Set up pre-commit hooks. Prevent problems before they become permanent history.