Last active
August 7, 2026 20:07
-
-
Save nerdCopter/898c89dcf469fe3210ef8c69b586513d to your computer and use it in GitHub Desktop.
Sync every git worktree with its PR base branch (fetch, merge, push)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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