Upstream Fork Strategy

Upstream Fork Strategy: Modifying Another Team’s Frontend Codebase

Problem

We need to modify a frontend Vite codebase owned by a separate team and redeploy it. We cannot make modifications to the actual source code repository itself. Changes range from small tweaks (swap an API URL) to large replacements (entire flows/pages).

Fork the upstream repo, maintain a customized branch with our changes, and automate upstream syncing with a human review gate.

Why Not Patches?

Patches (subtree + .patch files, quilt-style) are used by Linux distros, Android, and browser forks. They make sense when you have:

  • 500+ independent modifications
  • Multiple base versions maintained simultaneously
  • Multiple consumers picking different subsets of patches
  • Patches submitted back upstream
  • Changes that must be independently reorderable/droppable

We have one team, one set of changes, one upstream version, one deployment. A branch is the right abstraction. If that ever changes, migration is trivial.

Why Not Vite Plugin Overrides?

Using resolve.alias to redirect imports to replacement modules works for swapping entire components, but:

  • Entire file replacement only — changing 2 lines in a 500-line component means duplicating 498 lines that silently drift from upstream
  • Tightly coupled to upstream’s import structure
  • No good story for small, surgical changes (strings, config values, feature flags)

The fork approach gives you line-level precision for small changes AND full file replacement for large ones, all with normal git tooling.


Branch Structure

gitGraph
    commit id: "upstream"
    commit id: "upstream 2"
    branch customized
    commit id: "hide billing"
    commit id: "swap logo"
    commit id: "remove footer"
    checkout main
    commit id: "upstream 3"
    commit id: "upstream 4"
    checkout customized
    merge main id: "merge main, resolve conflicts"
    commit id: "hide new billing page"

Two branches:

  • main — pristine mirror of upstream. Never commit your changes here.
  • customized — branched from main, contains only your modifications. This is what you deploy.

Initial Setup

# 1. Fork their repo via GitHub UI or CLI
gh repo fork <upstream-org>/<repo-name> --clone

# 2. Add upstream remote
git remote add upstream https://github.com/<upstream-org>/<repo-name>.git

# 3. Create your customized branch
git checkout -b customized main

# 4. Make your modifications, one logical change per commit
git commit -m "hide billing page from navigation"
git commit -m "replace onboarding flow with custom implementation"
git commit -m "swap API base URL to our backend"

# 5. Push
git push origin customized

Day-to-Day Workflows

Making a new modification

git checkout customized
# edit files
git commit -m "remove footer links to upstream marketing site"
git push origin customized

For larger changes, branch off customized, open a PR back into customized for team review.

Seeing exactly what we’ve changed vs upstream

git diff main...customized

This always shows your clean delta — nothing else.

Seeing a specific change in isolation

# Each commit is a discrete, reviewable unit
git log main...customized --oneline
git show <commit-hash>

Dropping a change

git revert <commit-hash>

Upstream Sync Process

What happens

flowchart LR
    A[upstream repo] --> B[sync main]
    B --> C[merge into customized]
    C --> D[diff report]
    D --> E[human review]
    E --> F[deploy]
    C -- conflicts --> G[developer resolves]
    G --> E

Automated sync (CI runs on schedule)

# Sync main with upstream (no conflicts possible — it's a mirror)
git fetch upstream
git checkout main
git merge upstream/main
git push origin main

# Attempt merge into customized
git checkout customized
git merge main

The merge into customized either succeeds cleanly or produces conflicts for a developer to resolve.

Review gate with watchlist

Not all upstream changes matter to us. A watchlist focuses human attention on changes that could break our intent (e.g., upstream adds a new billing page we haven’t patched out).

Watch configuration

# .upstream-review/watch.yaml
watch:
  - pattern: "src/pages/billing/**"
    reason: "We hide billing — new pages need to be removed"
  - pattern: "src/components/nav/**"
    reason: "We modify nav to remove billing links"
  - pattern: "src/routes.*"
    reason: "New routes may expose features we've removed"
  - pattern: "package.json"
    reason: "Dependency changes may affect our build"

CI behavior on upstream sync

ScenarioCI action
No conflicts, no watched files changedOpens PR, minimal review needed
No conflicts, watched files changedOpens PR, flags for careful review
ConflictsOpens draft PR, assigns developer to resolve

What the review PR looks like

Upstream sync: abc123..def456 (12 commits)

Requires review (matched watch patterns):

  • src/pages/billing/UpgradePage.tsx — NEW FILE — “We hide billing”
  • src/routes.ts — MODIFIED (added /billing/upgrade route) — “New routes may expose features we’ve removed”

Patches applied cleanly: all commits merged

Unmatched changes (low risk): 47 files in src/components/dashboard/, src/utils/, …

A developer reviews, makes additional modifications if needed (e.g., hide the new billing page), and merges.


Commit Hygiene

The single most important practice: one logical change per commit, with a clear message.

Good:

hide billing page from navigation
replace onboarding flow with custom implementation
swap API base URL to our backend
remove footer links to upstream marketing site
add our analytics script to index.html
replace entire settings page with custom version

Bad:

updates
fix stuff
WIP
more changes

This matters because:

  • Each commit is independently revertable
  • git log main...customized --oneline gives an instant inventory of all your modifications
  • If you ever need to migrate to a patch-based workflow, git format-patch produces clean patches from clean commits

Switching Between Approaches

Neither approach is a one-way door.

Branch to Patches

git format-patch main...customized
# Produces:
#   0001-hide-billing-from-nav.patch
#   0002-replace-onboarding-flow.patch
#   0003-swap-api-base-url.patch

Your commits are already a patch series. git format-patch extracts them.

Patches to Branch

git checkout -b customized main
git am patches/*.patch

Apply in order, now you have a branch.

When to consider switching to patches

  • Merge conflicts become a weekly chore (~50+ modifications)
  • You need to toggle changes on/off per environment
  • Multiple deployments need different subsets of your changes
  • Your changes conflict with each other during upstream merges and you can’t untangle them

GitHub Actions: Automated Upstream Sync

# .github/workflows/upstream-sync.yaml
name: Upstream Sync

on:
  schedule:
    - cron: '0 9 * * 1-5' # Weekdays at 9 AM
  workflow_dispatch: # Manual trigger

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Configure git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Fetch upstream
        run: |
          git remote add upstream https://github.com/<upstream-org>/<repo>.git
          git fetch upstream

      - name: Sync main
        run: |
          git checkout main
          git merge upstream/main
          git push origin main

      - name: Attempt merge into customized
        id: merge
        run: |
          git checkout customized
          git merge main --no-commit --no-ff || true

          if git diff --cached --quiet; then
            echo "status=no-changes" >> "$GITHUB_OUTPUT"
          elif git diff --name-only --diff-filter=U | head -1 | grep -q .; then
            git merge --abort
            echo "status=conflicts" >> "$GITHUB_OUTPUT"
          else
            git merge --abort
            echo "status=clean" >> "$GITHUB_OUTPUT"
          fi

      - name: Check watched files
        if: steps.merge.outputs.status == 'clean'
        id: watch
        run: |
          CHANGED=$(git diff main...upstream/main --name-only)
          WATCHED=""

          while IFS= read -r pattern; do
            MATCHES=$(echo "$CHANGED" | grep -E "$pattern" || true)
            if [ -n "$MATCHES" ]; then
              WATCHED="$WATCHED$MATCHES"$'\n'
            fi
          done < <(yq '.watch[].pattern' .upstream-review/watch.yaml)

          if [ -n "$WATCHED" ]; then
            echo "watched_changes<<EOF" >> "$GITHUB_OUTPUT"
            echo "$WATCHED" >> "$GITHUB_OUTPUT"
            echo "EOF" >> "$GITHUB_OUTPUT"
            echo "has_watched=true" >> "$GITHUB_OUTPUT"
          else
            echo "has_watched=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Create sync branch and PR
        if: steps.merge.outputs.status != 'no-changes'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          MERGE_STATUS: ${{ steps.merge.outputs.status }}
          HAS_WATCHED: ${{ steps.watch.outputs.has_watched }}
          WATCHED_CHANGES: ${{ steps.watch.outputs.watched_changes }}
        run: |
          BRANCH="upstream-sync/$(date +%Y-%m-%d)"
          git checkout customized
          git checkout -b "$BRANCH"
          git merge main || true
          git push origin "$BRANCH"

          if [ "$MERGE_STATUS" = "conflicts" ]; then
            TITLE="[CONFLICTS] Upstream sync $(date +%Y-%m-%d)"
            BODY="## Merge conflicts — manual resolution required"
            DRAFT="--draft"
          elif [ "$HAS_WATCHED" = "true" ]; then
            TITLE="[REVIEW] Upstream sync $(date +%Y-%m-%d)"
            BODY="## Watched files changed — review required

          **Changed watched files:**
          \`\`\`
          $WATCHED_CHANGES
          \`\`\`

          Check if new modifications are needed (e.g., new billing pages to hide)."
            DRAFT=""
          else
            TITLE="Upstream sync $(date +%Y-%m-%d)"
            BODY="## Clean sync — no watched files affected

          Low-risk merge. Upstream changes don't touch areas we've modified."
            DRAFT=""
          fi

          gh pr create \
            --base customized \
            --head "$BRANCH" \
            --title "$TITLE" \
            --body "$BODY" \
            $DRAFT

Summary

WhatHow
Our changes live oncustomized branch
Upstream is tracked onmain branch (pristine mirror)
See our full deltagit diff main...customized
Upstream syncCI merges main into customized, opens PR
Review gateWatchlist flags changes in sensitive areas
Deploy fromcustomized branch
Drop a changegit revert <commit>
Escape hatchgit format-patch to migrate to patch-based workflow