Skip to content

Instantly share code, notes, and snippets.

@nerdCopter
Last active August 7, 2026 20:07
Show Gist options
  • Select an option

  • Save nerdCopter/898c89dcf469fe3210ef8c69b586513d to your computer and use it in GitHub Desktop.

Select an option

Save nerdCopter/898c89dcf469fe3210ef8c69b586513d to your computer and use it in GitHub Desktop.
Sync every git worktree with its PR base branch (fetch, merge, push)
#!/bin/bash
#
# sync-worktrees.bash
#
# SOURCE THIS FILE — do not execute it directly.
#
# Keeps every `git worktree` in a repo up to date with its PR's base branch,
# merging and pushing where needed, and skips worktrees that are already
# merged, up to date, or whose PR is MERGED.
#
# Requires: git, GitHub CLI (`gh`), each worktree's branch tracking an
# open PR (or at least an upstream branch) so the base branch can be detected.
#
# Usage:
# source sync-worktrees.bash
# saw # skips worktrees with no open PR (NO_PR)
# saw --include-no-pr # also merges NO_PR worktrees
#
# `saw` runs sync_all_worktrees, which fetches all remotes once (worktrees
# share one .git, so a single fetch covers all of them), then loops over
# every worktree except the one you started in, calling sync_worktree on
# each. A worktree is skipped if it has uncommitted changes; a merge
# conflict is aborted and reverted rather than left unresolved.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "This file defines shell functions and must be sourced, not executed:" >&2
echo " source ${BASH_SOURCE[0]}" >&2
exit 1
fi
sync_worktree() {
local target_dir="$1"
local include_no_pr="$2" # "1" to also merge worktrees with no open PR
if [ ! -d "$target_dir" ]; then
echo "Directory $target_dir does not exist. Skipping."
return 1
fi
cd "$target_dir" || return 1
local branch
branch=$(git rev-parse --abbrev-ref HEAD)
echo "==> Checked into worktree: $target_dir ($branch)"
# Never merge over uncommitted work. Untracked files are excluded on
# purpose -- they don't block a merge and are common noise (build
# artifacts, IDE files) in these worktrees.
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo " Skipping: uncommitted changes present."
return 1
fi
# Check and display GitHub PR state
local pr_state
pr_state=$(gh pr view --json state --jq '.state' 2>/dev/null)
echo " GitHub PR Status: ${pr_state:-NO_PR}"
if [ "$pr_state" = "MERGED" ]; then
echo " Skipping: PR is already MERGED."
return 0
fi
if [ -z "$pr_state" ] && [ "$include_no_pr" != "1" ]; then
echo " Skipping: NO_PR (pass --include-no-pr to sync_all_worktrees to merge these too)."
return 0
fi
# DYNAMIC BASE DETECTION
local base_branch=""
base_branch=$(gh pr view --json baseRefName --jq '.baseRefName' 2>/dev/null)
if [ -n "$base_branch" ]; then
base_branch="upstream/${base_branch#upstream/}"
fi
if [ -z "$base_branch" ]; then
base_branch=$(git rev-parse --abbrev-ref --symbolic-full-name @{upstream} 2>/dev/null)
fi
if [ -z "$base_branch" ]; then
base_branch=$(git symbolic-ref refs/remotes/upstream/HEAD 2>/dev/null | sed 's@refs/remotes/@@')
fi
if [ -z "$base_branch" ]; then
base_branch="upstream/master"
fi
echo " Detected Base Branch: $base_branch"
# 1. Check if the feature branch itself is merged into the base
if git branch -r --merged "$base_branch" | grep -qE "(${branch}|origin/${branch})$"; then
echo " Skipping: Branch is already merged into $base_branch."
return 0
fi
# 2. Check if base_branch has any NEW commits that HEAD doesn't have yet
local unmerged_commits
unmerged_commits=$(git log HEAD.."$base_branch" --oneline 2>/dev/null)
if [ -z "$unmerged_commits" ]; then
echo " Skipping: Worktree is already up-to-date with $base_branch."
return 0
fi
# Perform merge only when there are actual new commits from base_branch.
# On conflict, abort and revert rather than leaving the merge unresolved.
echo " Worktree needs updates from $base_branch. Merging..."
if git merge "$base_branch"; then
git push
else
echo " ERROR: Merge conflict merging $base_branch into $branch."
if git rev-parse -q --verify MERGE_HEAD >/dev/null; then
git merge --abort
echo " Reverted: merge aborted, worktree restored to pre-merge state."
fi
return 1
fi
}
sync_all_worktrees() {
local include_no_pr=""
if [ "$1" = "--include-no-pr" ]; then
include_no_pr="1"
fi
local original_dir="$PWD"
# Refresh remote-tracking refs once: all worktrees share the same .git,
# so a single fetch here covers every worktree in the loop below.
git fetch --all
# Reads line-by-line from git worktree list
# awk '{print $1}' grabs the directory path (first column)
while read -r wt_path; do
# Skip the main repo directory where you started
if [ "$wt_path" = "$original_dir" ]; then
continue
fi
echo ""
sync_worktree "$wt_path" "$include_no_pr"
done < <(git worktree list | awk '{print $1}')
# Return back to where you started
cd "$original_dir" || return
echo ""
echo "==> Finished syncing all worktrees. Returned to: $original_dir"
}
alias saw='sync_all_worktrees'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment