Forking a repository on GitHub is a familiar task — you want to contribute to open source, customize a library, or simply keep a copy for experimentation. The problem starts when the original project (upstream) continues to evolve while your fork stays frozen.
After a few months, upstream has 200 new commits, important bug fixes, new features — but your fork is still at the old commit from when you first created it. When you open a Pull Request, conflicts pile up, CI fails constantly, and merging becomes a nightmare. This isn’t anyone’s fault — it’s Git’s architecture: forks and upstream develop independently with no automatic connection.
Why Forks Fall Behind and Signs You Need to Sync Now
Every time you fork a repo, GitHub creates an independent snapshot at that point in time. When upstream merges new PRs, releases hotfixes, updates dependencies — none of that automatically appears in your fork. The two branches develop in parallel, and the gap keeps widening.
Signs that you need to sync right away:
- GitHub shows “This branch is X commits behind upstream/main”
- Your CI is failing because upstream changed an interface or API
- You want to open a PR to upstream but there are too many conflicts
- A security advisory was just patched upstream but your fork doesn’t have it yet
3 Ways to Sync a Fork with Upstream — Real-World Comparison
Method 1: GitHub UI “Sync fork” Button
GitHub launched the “Sync fork” button directly in the web interface in 2022. Go to your fork, look at the branch status line, click Sync fork → Update branch. Done.
Simple, no need to open a terminal. But there are clear limitations: it can only sync when there are no conflicts. If you’ve edited a file that upstream also edited — the GitHub UI gives up and forces you to resolve it manually.
Method 2: Git CLI (Manual)
The traditional approach, with full control. First time, add the upstream remote then fetch:
# First time: add upstream remote
git remote add upstream https://github.com/original-owner/original-repo.git
# Fetch changes from upstream (not yet merged)
git fetch upstream
# Merge upstream into the local branch
git checkout main
git merge upstream/main
# Push to fork
git push origin main
Or use rebase instead of merge to keep history clean:
git fetch upstream
git checkout main
git rebase upstream/main
git push origin main --force-with-lease
An important note here: I use --force-with-lease instead of --force. I once lost important code from accidentally force pushing the wrong branch — since then I’ve always been careful with git push --force. The --force-with-lease flag will reject the push if the remote has new commits that your local doesn’t know about, making it much safer than blind force pushing.
Method 3: Automation with GitHub Actions
If your fork is long-term and you don’t want to remember to sync manually every week, GitHub Actions is the definitive solution. Create a workflow that runs on a schedule, automatically fetches upstream and merges — you don’t need to do anything else.
Pros and Cons Analysis — Which Method Is Right for You?
| Criteria | GitHub UI | Git CLI | GitHub Actions |
|---|---|---|---|
| Ease of use | Very high | Medium | Low (initial setup) |
| Conflict handling | No | Yes | Yes (needs config) |
| Fully automatic | No | No | Yes |
| Best when | Temporary sync, few changes | Need control, have conflicts | Long-term fork, production |
Practical decision: if the fork is just for reading code or short-term experimentation → use GitHub UI. If you’re maintaining a fork with your own customizations and need to control every change → CLI with rebase. If the fork is an actual product running in production and you need to always keep up with upstream → automated GitHub Actions.
Setting Up GitHub Actions to Automatically Sync Your Fork
Create the file .github/workflows/sync-upstream.yml in your fork:
name: Sync Fork with Upstream
on:
schedule:
# Run every day at 6 AM UTC (1 PM JST)
- cron: '0 6 * * *'
workflow_dispatch: # Allow manual triggering from the UI
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout fork
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Add upstream remote
run: |
git remote add upstream https://github.com/original-owner/original-repo.git
git fetch upstream
- name: Merge upstream into main
run: |
git checkout main
git merge upstream/main --no-edit
git push origin main
Replace original-owner/original-repo with the actual upstream URL. The workflow runs every day, automatically fetches upstream and merges into main. If there’s nothing new from upstream, the merge step finishes quickly without doing anything.
Smart Conflict Resolution When Syncing
The tricky case: you’ve edited file A in your fork, and upstream also edited file A. GitHub Actions will fail at the merge step. There are 2 strategies depending on the situation:
Strategy 1: Prioritize Upstream (Override with Theirs)
When you want to always get the latest version from upstream and accept overriding your customizations at conflicting files:
git fetch upstream
git checkout main
git merge -X theirs upstream/main
git push origin main
Use when: the fork only adds small features in separate files and doesn’t modify upstream’s core files.
Strategy 2: Preserve Customizations with a Separate Branch + Rebase
This is the pattern I use most for long-term forks: keep main as a clean mirror of upstream, with all customizations living in a separate branch (my-fork, custom-features, etc.).
# main always syncs with upstream
git fetch upstream
git checkout main
git merge upstream/main
git push origin main
# Rebase customizations on top of the latest upstream
git checkout my-custom-branch
git rebase main
# If there are conflicts, handle them commit by commit
# git add . && git rebase --continue (after fixing the conflict)
# git rebase --abort (if you want to cancel and start over)
git push origin my-custom-branch --force-with-lease
A clear separation between “code from upstream” and “my own code” means far fewer conflicts in the long run.
Automatically Create Issues When Sync Fails
Add a step to the workflow to receive notifications when there’s a conflict that needs manual resolution:
- name: Notify on failure
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Sync upstream failed — manual intervention needed',
body: 'Upstream sync workflow failed. Manual conflict resolution needed.\n\nRun ID: ${{ github.run_id }}'
})
When the workflow fails, an Issue is automatically created in the repo with a link to the run log. You don’t need to check the Actions tab every day.
A Few Status Check Commands and Practical Notes
Before syncing, check how far behind your fork is:
# Number of commits the fork is behind upstream
git fetch upstream
git rev-list --count HEAD..upstream/main
# List of upstream commits not yet in the fork
git log HEAD..upstream/main --oneline
# Which files have changed in upstream
git diff HEAD..upstream/main --name-only
A few things to keep in mind when working with long-term forks:
- Don’t commit directly to main if the fork has customizations — it’s easy to conflict every time you sync
- Sync frequently (at least weekly) — syncing early with fewer diverging commits is far easier than letting three months pile up
- Read the upstream CHANGELOG before merging a major version — breaking changes need thorough testing before applying to production
- Tag a version before a large sync — if something goes wrong, you have a clear rollback point:
git tag v1.2.3-before-sync
Automated upstream syncing isn’t completely “set and forget” — you’ll still need to check notifications when workflows fail and occasionally resolve conflicts manually. But instead of spending two hours untangling conflicts every month because you haven’t synced in a long time, you only spend 10 minutes each time, and your fork stays in a ready-to-use state.

