Skip to content

Commit 716a4eb

Browse files
authored
Merge pull request #43 from githubnext/copilot/update-fast-forward-branch-strategy
Fast-forward autoloop/* branches when ahead=0 instead of merging
2 parents 391371c + a7c81f5 commit 716a4eb

5 files changed

Lines changed: 198 additions & 35 deletions

File tree

.github/workflows/sync-branches.lock.yml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/sync-branches.md

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,82 @@ steps:
4848
4949
print(f"Found {len(branches)} autoloop branch(es) to sync: {branches}")
5050
51+
def rev_count(range_spec):
52+
r = subprocess.run(
53+
["git", "rev-list", "--count", range_spec],
54+
capture_output=True, text=True
55+
)
56+
if r.returncode != 0:
57+
return None
58+
try:
59+
return int(r.stdout.strip())
60+
except ValueError:
61+
return None
62+
5163
failed = []
5264
for branch in branches:
5365
print(f"\n--- Syncing {branch} with {default_branch} ---")
5466
55-
# Fetch both branches
67+
# Fetch both branches so the ahead/behind counts below are computed
68+
# against up-to-date local copies of the remote tips.
5669
subprocess.run(["git", "fetch", "origin", branch], capture_output=True)
5770
subprocess.run(["git", "fetch", "origin", default_branch], capture_output=True)
5871
59-
# Check out the program branch
72+
# Compute ahead/behind counts using the remote-tracking refs so we
73+
# make a decision based on commit delta (not content delta).
74+
ahead = rev_count(f"origin/{default_branch}..origin/{branch}")
75+
behind = rev_count(f"origin/{branch}..origin/{default_branch}")
76+
if ahead is None or behind is None:
77+
print(f" Failed to compute ahead/behind for {branch}")
78+
failed.append(branch)
79+
continue
80+
print(f" ahead={ahead} behind={behind}")
81+
82+
if ahead == 0 and behind > 0:
83+
# All of the branch's commits are already in the default branch.
84+
# Merging would produce a noisy "Merge main into branch" commit
85+
# that re-exposes every historical file as a patch touch — the
86+
# failure mode that triggers gh-aw's E003 (>100 files) when a
87+
# new PR is opened. Fast-forward the canonical branch instead.
88+
# This is lossless because ahead=0 proves every commit on the
89+
# branch is already reachable from the default branch.
90+
ff = subprocess.run(
91+
["git", "checkout", "-B", branch, f"origin/{default_branch}"],
92+
capture_output=True, text=True
93+
)
94+
if ff.returncode != 0:
95+
print(f" Failed to fast-forward {branch}: {ff.stderr}")
96+
failed.append(branch)
97+
continue
98+
# Use --force-with-lease so that if anyone else is simultaneously
99+
# pushing to the branch, the update is rejected rather than
100+
# overwriting their commits.
101+
push = subprocess.run(
102+
["git", "push", "--force-with-lease", "origin", branch],
103+
capture_output=True, text=True
104+
)
105+
if push.returncode != 0:
106+
print(f" Failed to force-push {branch}: {push.stderr}")
107+
failed.append(branch)
108+
continue
109+
print(f" Fast-forwarded {branch} to origin/{default_branch}")
110+
continue
111+
112+
if ahead == 0 and behind == 0:
113+
# Already at default branch — nothing to do.
114+
print(f" {branch} is already up to date with origin/{default_branch}")
115+
continue
116+
117+
if ahead > 0 and behind == 0:
118+
# Unique work preserved; no upstream drift to merge.
119+
print(f" {branch} is ahead of origin/{default_branch} with no upstream drift; nothing to merge.")
120+
continue
121+
122+
# True divergence (ahead > 0 and behind > 0): check out and merge.
60123
checkout = subprocess.run(
61-
["git", "checkout", branch],
124+
["git", "checkout", "-B", branch, f"origin/{branch}"],
62125
capture_output=True, text=True
63126
)
64-
if checkout.returncode != 0:
65-
# Try creating a local tracking branch
66-
checkout = subprocess.run(
67-
["git", "checkout", "-b", branch, f"origin/{branch}"],
68-
capture_output=True, text=True
69-
)
70127
if checkout.returncode != 0:
71128
print(f" Failed to checkout {branch}: {checkout.stderr}")
72129
failed.append(branch)

tests/test_scheduling.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -710,18 +710,31 @@ def _load_steps(self):
710710
return step_names
711711

712712
def _load_lock_steps(self):
713-
"""Return the list of step names from .github/workflows/sync-branches.lock.yml."""
713+
"""Return the list of step names from the agent job in
714+
.github/workflows/sync-branches.lock.yml.
715+
716+
Parsed with a regex (rather than PyYAML) so the test has no
717+
external dependencies beyond pytest.
718+
"""
714719
import os
715-
import yaml
716720

717721
lock_path = os.path.join(
718722
os.path.dirname(__file__), "..", ".github", "workflows", "sync-branches.lock.yml"
719723
)
720724
with open(lock_path) as f:
721-
data = yaml.safe_load(f)
722-
# Collect step names from the 'agent' job
723-
steps = data.get("jobs", {}).get("agent", {}).get("steps", [])
724-
return [s.get("name", "") for s in steps if s.get("name")]
725+
content = f.read()
726+
# Restrict to the 'agent:' job body so we don't pick up step names
727+
# from other jobs (e.g. 'activation').
728+
agent_match = re.search(r"^ agent:\n((?: .*\n|\n)+)", content, re.MULTILINE)
729+
if not agent_match:
730+
return []
731+
agent_body = agent_match.group(1)
732+
# Step names appear as either ' - name: <Name>' or
733+
# ' name: <Name>' (when the step starts with '- env:').
734+
step_names = []
735+
for m in re.finditer(r'^\s{6,8}(?:- )?name:\s*(.+)$', agent_body, re.MULTILINE):
736+
step_names.append(m.group(1).strip())
737+
return step_names
725738

726739
def test_cred_step_exists(self):
727740
"""A step that configures Git identity/auth must exist in the source."""

0 commit comments

Comments
 (0)