diff --git a/.github/workflows/sync-branches.lock.yml b/.github/workflows/sync-branches.lock.yml index 89d965b..6972f09 100644 --- a/.github/workflows/sync-branches.lock.yml +++ b/.github/workflows/sync-branches.lock.yml @@ -24,7 +24,7 @@ # Runs whenever the default branch changes and merges it into all active # autoloop/* branches so that program iterations always build on the latest code. # -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1ef97f2f3c4b1726702db78fc5e36ea72b0ee7cdf378cc88263a3d41d61b6ac7","compiler_version":"v0.65.6","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"7347655448caf952972200853ace37356f693514cb8a4ae018797501b79c86a5","compiler_version":"v0.65.6","strict":true,"agent_id":"copilot"} name: "Sync Branches" "on": @@ -139,13 +139,13 @@ jobs: run: | bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh { - cat << 'GH_AW_PROMPT_2f026df8ba761c4c_EOF' + cat << 'GH_AW_PROMPT_4db17a6c15417a22_EOF' - GH_AW_PROMPT_2f026df8ba761c4c_EOF + GH_AW_PROMPT_4db17a6c15417a22_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" - cat << 'GH_AW_PROMPT_2f026df8ba761c4c_EOF' + cat << 'GH_AW_PROMPT_4db17a6c15417a22_EOF' The following GitHub context information is available for this workflow: {{#if __GH_AW_GITHUB_ACTOR__ }} @@ -174,12 +174,12 @@ jobs: {{/if}} - GH_AW_PROMPT_2f026df8ba761c4c_EOF + GH_AW_PROMPT_4db17a6c15417a22_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_prompt.md" - cat << 'GH_AW_PROMPT_2f026df8ba761c4c_EOF' + cat << 'GH_AW_PROMPT_4db17a6c15417a22_EOF' {{#runtime-import .github/workflows/sync-branches.md}} - GH_AW_PROMPT_2f026df8ba761c4c_EOF + GH_AW_PROMPT_4db17a6c15417a22_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -289,7 +289,7 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GITHUB_REPOSITORY: ${{ github.repository }} name: Merge default branch into all autoloop program branches - run: "python3 - << 'PYEOF'\nimport os, subprocess, sys\n\ntoken = os.environ.get(\"GITHUB_TOKEN\", \"\")\nrepo = os.environ.get(\"GITHUB_REPOSITORY\", \"\")\ndefault_branch = os.environ.get(\"DEFAULT_BRANCH\", \"main\")\n\n# List all remote branches matching the autoloop/* pattern\nresult = subprocess.run(\n [\"git\", \"branch\", \"-r\", \"--list\", \"origin/autoloop/*\"],\n capture_output=True, text=True\n)\nif result.returncode != 0:\n print(f\"Failed to list remote branches: {result.stderr}\")\n sys.exit(0)\n\nbranches = [b.strip().replace(\"origin/\", \"\") for b in result.stdout.strip().split(\"\\n\") if b.strip()]\n\nif not branches:\n print(\"No autoloop/* branches found. Nothing to sync.\")\n sys.exit(0)\n\nprint(f\"Found {len(branches)} autoloop branch(es) to sync: {branches}\")\n\nfailed = []\nfor branch in branches:\n print(f\"\\n--- Syncing {branch} with {default_branch} ---\")\n\n # Fetch both branches\n subprocess.run([\"git\", \"fetch\", \"origin\", branch], capture_output=True)\n subprocess.run([\"git\", \"fetch\", \"origin\", default_branch], capture_output=True)\n\n # Check out the program branch\n checkout = subprocess.run(\n [\"git\", \"checkout\", branch],\n capture_output=True, text=True\n )\n if checkout.returncode != 0:\n # Try creating a local tracking branch\n checkout = subprocess.run(\n [\"git\", \"checkout\", \"-b\", branch, f\"origin/{branch}\"],\n capture_output=True, text=True\n )\n if checkout.returncode != 0:\n print(f\" Failed to checkout {branch}: {checkout.stderr}\")\n failed.append(branch)\n continue\n\n # Merge the default branch into the program branch\n merge = subprocess.run(\n [\"git\", \"merge\", f\"origin/{default_branch}\", \"--no-edit\",\n \"-m\", f\"Merge {default_branch} into {branch}\"],\n capture_output=True, text=True\n )\n if merge.returncode != 0:\n print(f\" Merge conflict or failure for {branch}: {merge.stderr}\")\n # Abort the merge to leave a clean state\n subprocess.run([\"git\", \"merge\", \"--abort\"], capture_output=True)\n failed.append(branch)\n continue\n\n # Push the updated branch\n push = subprocess.run(\n [\"git\", \"push\", \"origin\", branch],\n capture_output=True, text=True\n )\n if push.returncode != 0:\n print(f\" Failed to push {branch}: {push.stderr}\")\n failed.append(branch)\n continue\n\n print(f\" Successfully synced {branch}\")\n\n# Return to default branch\nsubprocess.run([\"git\", \"checkout\", default_branch], capture_output=True)\n\nif failed:\n print(f\"\\n⚠️ Failed to sync {len(failed)} branch(es): {failed}\")\n print(\"These branches may need manual conflict resolution.\")\n # Don't fail the workflow — log the issue but continue\nelse:\n print(f\"\\n✅ All {len(branches)} branch(es) synced successfully.\")\nPYEOF" + run: "python3 - << 'PYEOF'\nimport os, subprocess, sys\n\ntoken = os.environ.get(\"GITHUB_TOKEN\", \"\")\nrepo = os.environ.get(\"GITHUB_REPOSITORY\", \"\")\ndefault_branch = os.environ.get(\"DEFAULT_BRANCH\", \"main\")\n\n# List all remote branches matching the autoloop/* pattern\nresult = subprocess.run(\n [\"git\", \"branch\", \"-r\", \"--list\", \"origin/autoloop/*\"],\n capture_output=True, text=True\n)\nif result.returncode != 0:\n print(f\"Failed to list remote branches: {result.stderr}\")\n sys.exit(0)\n\nbranches = [b.strip().replace(\"origin/\", \"\") for b in result.stdout.strip().split(\"\\n\") if b.strip()]\n\nif not branches:\n print(\"No autoloop/* branches found. Nothing to sync.\")\n sys.exit(0)\n\nprint(f\"Found {len(branches)} autoloop branch(es) to sync: {branches}\")\n\ndef rev_count(range_spec):\n r = subprocess.run(\n [\"git\", \"rev-list\", \"--count\", range_spec],\n capture_output=True, text=True\n )\n if r.returncode != 0:\n return None\n try:\n return int(r.stdout.strip())\n except ValueError:\n return None\n\nfailed = []\nfor branch in branches:\n print(f\"\\n--- Syncing {branch} with {default_branch} ---\")\n\n # Fetch both branches so the ahead/behind counts below are computed\n # against up-to-date local copies of the remote tips.\n subprocess.run([\"git\", \"fetch\", \"origin\", branch], capture_output=True)\n subprocess.run([\"git\", \"fetch\", \"origin\", default_branch], capture_output=True)\n\n # Compute ahead/behind counts using the remote-tracking refs so we\n # make a decision based on commit delta (not content delta).\n ahead = rev_count(f\"origin/{default_branch}..origin/{branch}\")\n behind = rev_count(f\"origin/{branch}..origin/{default_branch}\")\n if ahead is None or behind is None:\n print(f\" Failed to compute ahead/behind for {branch}\")\n failed.append(branch)\n continue\n print(f\" ahead={ahead} behind={behind}\")\n\n if ahead == 0 and behind > 0:\n # All of the branch's commits are already in the default branch.\n # Merging would produce a noisy \"Merge main into branch\" commit\n # that re-exposes every historical file as a patch touch — the\n # failure mode that triggers gh-aw's E003 (>100 files) when a\n # new PR is opened. Fast-forward the canonical branch instead.\n # This is lossless because ahead=0 proves every commit on the\n # branch is already reachable from the default branch.\n ff = subprocess.run(\n [\"git\", \"checkout\", \"-B\", branch, f\"origin/{default_branch}\"],\n capture_output=True, text=True\n )\n if ff.returncode != 0:\n print(f\" Failed to fast-forward {branch}: {ff.stderr}\")\n failed.append(branch)\n continue\n # Use --force-with-lease so that if anyone else is simultaneously\n # pushing to the branch, the update is rejected rather than\n # overwriting their commits.\n push = subprocess.run(\n [\"git\", \"push\", \"--force-with-lease\", \"origin\", branch],\n capture_output=True, text=True\n )\n if push.returncode != 0:\n print(f\" Failed to force-push {branch}: {push.stderr}\")\n failed.append(branch)\n continue\n print(f\" Fast-forwarded {branch} to origin/{default_branch}\")\n continue\n\n if ahead == 0 and behind == 0:\n # Already at default branch — nothing to do.\n print(f\" {branch} is already up to date with origin/{default_branch}\")\n continue\n\n if ahead > 0 and behind == 0:\n # Unique work preserved; no upstream drift to merge.\n print(f\" {branch} is ahead of origin/{default_branch} with no upstream drift; nothing to merge.\")\n continue\n\n # True divergence (ahead > 0 and behind > 0): check out and merge.\n checkout = subprocess.run(\n [\"git\", \"checkout\", \"-B\", branch, f\"origin/{branch}\"],\n capture_output=True, text=True\n )\n if checkout.returncode != 0:\n print(f\" Failed to checkout {branch}: {checkout.stderr}\")\n failed.append(branch)\n continue\n\n # Merge the default branch into the program branch\n merge = subprocess.run(\n [\"git\", \"merge\", f\"origin/{default_branch}\", \"--no-edit\",\n \"-m\", f\"Merge {default_branch} into {branch}\"],\n capture_output=True, text=True\n )\n if merge.returncode != 0:\n print(f\" Merge conflict or failure for {branch}: {merge.stderr}\")\n # Abort the merge to leave a clean state\n subprocess.run([\"git\", \"merge\", \"--abort\"], capture_output=True)\n failed.append(branch)\n continue\n\n # Push the updated branch\n push = subprocess.run(\n [\"git\", \"push\", \"origin\", branch],\n capture_output=True, text=True\n )\n if push.returncode != 0:\n print(f\" Failed to push {branch}: {push.stderr}\")\n failed.append(branch)\n continue\n\n print(f\" Successfully synced {branch}\")\n\n# Return to default branch\nsubprocess.run([\"git\", \"checkout\", default_branch], capture_output=True)\n\nif failed:\n print(f\"\\n⚠️ Failed to sync {len(failed)} branch(es): {failed}\")\n print(\"These branches may need manual conflict resolution.\")\n # Don't fail the workflow — log the issue but continue\nelse:\n print(f\"\\n✅ All {len(branches)} branch(es) synced successfully.\")\nPYEOF" - name: Checkout PR branch id: checkout-pr if: | @@ -345,7 +345,7 @@ jobs: export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.11' mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_cf6e06fbb22b66a8_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_c44c901ef7ee68bd_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh { "mcpServers": { "github": { @@ -372,7 +372,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_cf6e06fbb22b66a8_EOF + GH_AW_MCP_CONFIG_c44c901ef7ee68bd_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/sync-branches.md b/.github/workflows/sync-branches.md index a45df2d..3d87064 100644 --- a/.github/workflows/sync-branches.md +++ b/.github/workflows/sync-branches.md @@ -48,25 +48,82 @@ steps: print(f"Found {len(branches)} autoloop branch(es) to sync: {branches}") + def rev_count(range_spec): + r = subprocess.run( + ["git", "rev-list", "--count", range_spec], + capture_output=True, text=True + ) + if r.returncode != 0: + return None + try: + return int(r.stdout.strip()) + except ValueError: + return None + failed = [] for branch in branches: print(f"\n--- Syncing {branch} with {default_branch} ---") - # Fetch both branches + # Fetch both branches so the ahead/behind counts below are computed + # against up-to-date local copies of the remote tips. subprocess.run(["git", "fetch", "origin", branch], capture_output=True) subprocess.run(["git", "fetch", "origin", default_branch], capture_output=True) - # Check out the program branch + # Compute ahead/behind counts using the remote-tracking refs so we + # make a decision based on commit delta (not content delta). + ahead = rev_count(f"origin/{default_branch}..origin/{branch}") + behind = rev_count(f"origin/{branch}..origin/{default_branch}") + if ahead is None or behind is None: + print(f" Failed to compute ahead/behind for {branch}") + failed.append(branch) + continue + print(f" ahead={ahead} behind={behind}") + + if ahead == 0 and behind > 0: + # All of the branch's commits are already in the default branch. + # Merging would produce a noisy "Merge main into branch" commit + # that re-exposes every historical file as a patch touch — the + # failure mode that triggers gh-aw's E003 (>100 files) when a + # new PR is opened. Fast-forward the canonical branch instead. + # This is lossless because ahead=0 proves every commit on the + # branch is already reachable from the default branch. + ff = subprocess.run( + ["git", "checkout", "-B", branch, f"origin/{default_branch}"], + capture_output=True, text=True + ) + if ff.returncode != 0: + print(f" Failed to fast-forward {branch}: {ff.stderr}") + failed.append(branch) + continue + # Use --force-with-lease so that if anyone else is simultaneously + # pushing to the branch, the update is rejected rather than + # overwriting their commits. + push = subprocess.run( + ["git", "push", "--force-with-lease", "origin", branch], + capture_output=True, text=True + ) + if push.returncode != 0: + print(f" Failed to force-push {branch}: {push.stderr}") + failed.append(branch) + continue + print(f" Fast-forwarded {branch} to origin/{default_branch}") + continue + + if ahead == 0 and behind == 0: + # Already at default branch — nothing to do. + print(f" {branch} is already up to date with origin/{default_branch}") + continue + + if ahead > 0 and behind == 0: + # Unique work preserved; no upstream drift to merge. + print(f" {branch} is ahead of origin/{default_branch} with no upstream drift; nothing to merge.") + continue + + # True divergence (ahead > 0 and behind > 0): check out and merge. checkout = subprocess.run( - ["git", "checkout", branch], + ["git", "checkout", "-B", branch, f"origin/{branch}"], capture_output=True, text=True ) - if checkout.returncode != 0: - # Try creating a local tracking branch - checkout = subprocess.run( - ["git", "checkout", "-b", branch, f"origin/{branch}"], - capture_output=True, text=True - ) if checkout.returncode != 0: print(f" Failed to checkout {branch}: {checkout.stderr}") failed.append(branch) diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 4944c3c..90e438e 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -786,18 +786,31 @@ def _load_steps(self): return step_names def _load_lock_steps(self): - """Return the list of step names from .github/workflows/sync-branches.lock.yml.""" + """Return the list of step names from the agent job in + .github/workflows/sync-branches.lock.yml. + + Parsed with a regex (rather than PyYAML) so the test has no + external dependencies beyond pytest. + """ import os - import yaml lock_path = os.path.join( os.path.dirname(__file__), "..", ".github", "workflows", "sync-branches.lock.yml" ) with open(lock_path) as f: - data = yaml.safe_load(f) - # Collect step names from the 'agent' job - steps = data.get("jobs", {}).get("agent", {}).get("steps", []) - return [s.get("name", "") for s in steps if s.get("name")] + content = f.read() + # Restrict to the 'agent:' job body so we don't pick up step names + # from other jobs (e.g. 'activation'). + agent_match = re.search(r"^ agent:\n((?: .*\n|\n)+)", content, re.MULTILINE) + if not agent_match: + return [] + agent_body = agent_match.group(1) + # Step names appear as either ' - name: ' or + # ' name: ' (when the step starts with '- env:'). + step_names = [] + for m in re.finditer(r'^\s{6,8}(?:- )?name:\s*(.+)$', agent_body, re.MULTILINE): + step_names.append(m.group(1).strip()) + return step_names def test_cred_step_exists(self): """A step that configures Git identity/auth must exist in the source.""" diff --git a/workflows/autoloop.md b/workflows/autoloop.md index 52c94a7..5583aa0 100644 --- a/workflows/autoloop.md +++ b/workflows/autoloop.md @@ -809,10 +809,53 @@ Each run executes **one iteration for the single selected program**: ### Step 3: Implement -1. Check out the program's long-running branch `autoloop/{program-name}`. If the branch does not yet exist, create it from the default branch. If it does exist: - - Fetch the default branch: `git fetch origin main`. - - Check whether the branch's changes have already been merged into main. If `git diff origin/main..autoloop/{program-name}` produces no output (i.e., every change on the branch is already on main), the branch is stale — **reset it to `origin/main`**: `git reset --hard origin/main`. - - Otherwise, merge the default branch into the long-running branch to pick up any upstream changes. +1. Check out the program's long-running branch `autoloop/{program-name}`, syncing it with the default branch using an explicit four-case decision tree based on commit ahead/behind counts. Run the following script (substituting `{program-name}`): + + ```bash + git fetch origin main + if git ls-remote --exit-code origin autoloop/{program-name}; then + # Branch exists — fetch it too so the ahead/behind counts below are + # computed against up-to-date local copies of the remote tips. + git fetch origin autoloop/{program-name} + + ahead=$(git rev-list --count origin/main..origin/autoloop/{program-name}) + behind=$(git rev-list --count origin/autoloop/{program-name}..origin/main) + + if [ "$ahead" = "0" ] && [ "$behind" != "0" ]; then + # All of the branch's commits are already in main (typical case after a + # successful merge of the previous iteration's PR). A merge here would + # produce a noisy "Merge main into branch" commit that re-exposes every + # historical file as a patch touch — the failure mode that triggers + # gh-aw's E003 (>100 files) when a new PR is opened. Fast-forward the + # canonical branch to main instead. This is lossless because ahead=0 + # proves every commit on the branch is already reachable from main. + git checkout -B autoloop/{program-name} origin/main + git push --force-with-lease origin autoloop/{program-name} + elif [ "$ahead" != "0" ] && [ "$behind" != "0" ]; then + # True divergence: branch has unique commits AND main has moved on. + git checkout -B autoloop/{program-name} origin/autoloop/{program-name} + git merge origin/main --no-edit -m "Merge main into autoloop/{program-name}" + else + # Already at main (ahead=0, behind=0) or only ahead of main (ahead>0, + # behind=0). Nothing to merge — just check out the branch. + git checkout -B autoloop/{program-name} origin/autoloop/{program-name} + fi + else + # Branch does not exist — create it from the default branch + git checkout -b autoloop/{program-name} origin/main + fi + ``` + + The four cases: + + | ahead | behind | Action | Rationale | + |---|---|---|---| + | 0 | 0 | checkout (nothing to do) | branch is exactly at main | + | 0 | >0 | **fast-forward + force-push** | branch's commits already in main; merging would produce noisy merge commit | + | >0 | 0 | checkout (nothing to do) | unique work preserved; no upstream drift to merge | + | >0 | >0 | checkout + merge | true divergence | + + Use `--force-with-lease` rather than `--force` so that if anyone else is simultaneously pushing to the branch, the update is rejected rather than overwriting their commits. 2. Make the proposed changes to the target files only. 3. **Respect the program constraints**: do not modify files outside the target list. diff --git a/workflows/sync-branches.md b/workflows/sync-branches.md index 29bacc1..cad1adb 100644 --- a/workflows/sync-branches.md +++ b/workflows/sync-branches.md @@ -96,19 +96,69 @@ steps: git('fetch', 'origin', branch); git('fetch', 'origin', defaultBranch); - // Check out the program branch - let checkout = git('checkout', branch); - if (checkout.returncode !== 0) { - // Try creating a local tracking branch - checkout = git('checkout', '-b', branch, 'origin/' + branch); + // Compute ahead/behind counts using the remote-tracking refs so we + // make a decision based on commit delta (not content delta). + const aheadResult = git('rev-list', '--count', + 'origin/' + defaultBranch + '..origin/' + branch); + const behindResult = git('rev-list', '--count', + 'origin/' + branch + '..origin/' + defaultBranch); + if (aheadResult.returncode !== 0 || behindResult.returncode !== 0) { + console.log(' Failed to compute ahead/behind for ' + branch + ': ' + + (aheadResult.stderr || behindResult.stderr)); + failed.push(branch); + continue; + } + const ahead = parseInt((aheadResult.stdout || '0').trim(), 10) || 0; + const behind = parseInt((behindResult.stdout || '0').trim(), 10) || 0; + console.log(' ahead=' + ahead + ' behind=' + behind); + + if (ahead === 0 && behind > 0) { + // All of the branch's commits are already in the default branch. + // Merging would produce a noisy "Merge main into branch" commit + // that re-exposes every historical file as a patch touch — the + // failure mode that triggers gh-aw's E003 (>100 files) when a + // new PR is opened. Fast-forward the canonical branch instead. + // This is lossless because ahead=0 proves every commit on the + // branch is already reachable from the default branch. + const ff = git('checkout', '-B', branch, 'origin/' + defaultBranch); + if (ff.returncode !== 0) { + console.log(' Failed to fast-forward ' + branch + ': ' + ff.stderr); + failed.push(branch); + continue; + } + // Use --force-with-lease so that if anyone else is simultaneously + // pushing to the branch, the update is rejected rather than + // overwriting their commits. + const push = git('push', '--force-with-lease', 'origin', branch); + if (push.returncode !== 0) { + console.log(' Failed to force-push ' + branch + ': ' + push.stderr); + failed.push(branch); + continue; + } + console.log(' Fast-forwarded ' + branch + ' to origin/' + defaultBranch); + continue; + } + + if (ahead === 0 && behind === 0) { + // Already at default branch — nothing to do. + console.log(' ' + branch + ' is already up to date with origin/' + defaultBranch); + continue; + } + + if (ahead > 0 && behind === 0) { + // Unique work preserved; no upstream drift to merge. + console.log(' ' + branch + ' is ahead of origin/' + defaultBranch + ' with no upstream drift; nothing to merge.'); + continue; } + + // True divergence (ahead > 0 && behind > 0): check out and merge. + let checkout = git('checkout', '-B', branch, 'origin/' + branch); if (checkout.returncode !== 0) { console.log(' Failed to checkout ' + branch + ': ' + checkout.stderr); failed.push(branch); continue; } - // Merge the default branch into the program branch const merge = git('merge', 'origin/' + defaultBranch, '--no-edit', '-m', 'Merge ' + defaultBranch + ' into ' + branch); if (merge.returncode !== 0) {