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) {