2020"""
2121
2222import re
23+ import shutil
2324import subprocess
2425import sys
25- from datetime import date
26+ from datetime import datetime
2627from pathlib import Path
2728
29+ GIT = shutil .which ("git" ) or "git"
2830
29- def run_git_command (args : list ) -> str :
31+
32+ def run_git_command (args : list [str ]) -> str :
3033 """Run a git command and return the output."""
3134 try :
32- result = subprocess .run (
33- ["git" ] + args ,
35+ result = subprocess .run ( # noqa: S603
36+ [GIT , * args ] ,
3437 capture_output = True ,
3538 text = True ,
3639 check = True ,
@@ -142,6 +145,73 @@ def check_working_directory_clean() -> None:
142145 sys .exit (1 )
143146
144147
148+ def check_up_to_date_with_remote () -> None :
149+ """Ensure the local branch is in sync with its remote before bumping.
150+
151+ Running the version bump from a stale local checkout can silently drop
152+ CHANGELOG.rst entries: the local file is missing changes (e.g. a prior
153+ release's changelog section) that already exist upstream, so the diff
154+ inserted by update_changelog() is based on outdated content and the
155+ upstream entry never makes it back in once pushed/merged.
156+ """
157+ branch = run_git_command (["rev-parse" , "--abbrev-ref" , "HEAD" ])
158+ upstream = subprocess .run ( # noqa: S603
159+ [GIT , "rev-parse" , "--abbrev-ref" , "--symbolic-full-name" , "@{u}" ],
160+ capture_output = True ,
161+ text = True ,
162+ )
163+ if upstream .returncode != 0 :
164+ print (
165+ f"Warning: Branch '{ branch } ' has no upstream tracking branch; "
166+ "skipping remote sync check." ,
167+ file = sys .stderr ,
168+ )
169+ return
170+
171+ upstream_ref = upstream .stdout .strip ()
172+
173+ # Fetch quietly so the comparison below reflects the latest remote state.
174+ fetch = subprocess .run ( # noqa: S603
175+ [GIT , "fetch" , "--quiet" ],
176+ capture_output = True ,
177+ text = True ,
178+ )
179+ if fetch .returncode != 0 :
180+ print (
181+ f"Warning: 'git fetch' failed, remote sync check may be stale: "
182+ f"{ fetch .stderr .strip ()} " ,
183+ file = sys .stderr ,
184+ )
185+
186+ local_sha = run_git_command (["rev-parse" , "HEAD" ])
187+ upstream_sha = run_git_command (["rev-parse" , upstream_ref ])
188+
189+ if local_sha == upstream_sha :
190+ return
191+
192+ behind = run_git_command (["rev-list" , "--count" , f"HEAD..{ upstream_ref } " ])
193+ ahead = run_git_command (["rev-list" , "--count" , f"{ upstream_ref } ..HEAD" ])
194+
195+ if int (behind ) > 0 :
196+ print (
197+ f"Error: Local branch '{ branch } ' is { behind } commit(s) behind "
198+ f"'{ upstream_ref } '." ,
199+ file = sys .stderr ,
200+ )
201+ print (
202+ "Pull the latest changes before bumping the version, otherwise "
203+ "CHANGELOG.rst entries from other releases may be lost." ,
204+ file = sys .stderr ,
205+ )
206+ sys .exit (1 )
207+
208+ if int (ahead ) > 0 :
209+ print (
210+ f"Warning: Local branch '{ branch } ' is { ahead } commit(s) ahead of "
211+ f"'{ upstream_ref } ' (unpushed commits)."
212+ )
213+
214+
145215def update_changelog (version : str ) -> None :
146216 """Insert a version heading into CHANGELOG.rst below the Unreleased section.
147217
@@ -169,15 +239,16 @@ def update_changelog(version: str) -> None:
169239
170240 content = changelog_path .read_text (encoding = "utf-8" )
171241
172- heading = f"Version { version } ({ date .today ().isoformat ()} )"
242+ today = datetime .now ().astimezone ().date ().isoformat ()
243+ heading = f"Version { version } ({ today } )"
173244 underline = "=" * len (heading )
174245 version_block = f"{ heading } \n { underline } \n "
175246
176247 # Match "Unreleased\n==========\n" (any number of = signs) followed by
177248 # one or more blank lines, then insert the version block after them.
178249 pattern = re .compile (
179250 r"(Unreleased\n=+\n)" # group 1: Unreleased heading
180- r"(\n+)" , # group 2: blank line(s) separator
251+ r"(\n+)" , # group 2: blank line(s) separator
181252 re .MULTILINE ,
182253 )
183254
@@ -192,10 +263,7 @@ def update_changelog(version: str) -> None:
192263
193264 # Insert the version block after the blank lines that follow "Unreleased"
194265 new_content = (
195- content [: match .end ()]
196- + version_block
197- + "\n "
198- + content [match .end () :]
266+ content [: match .end ()] + version_block + "\n " + content [match .end () :]
199267 )
200268
201269 changelog_path .write_text (new_content , encoding = "utf-8" )
@@ -205,20 +273,18 @@ def update_changelog(version: str) -> None:
205273def commit_changelog (version : str ) -> None :
206274 """Stage and commit the CHANGELOG.rst update."""
207275 run_git_command (["add" , "CHANGELOG.rst" ])
208- run_git_command (
209- ["commit" , "-m" , f"Update changelog for v{ version } " ]
210- )
276+ run_git_command (["commit" , "-m" , f"Update changelog for v{ version } " ])
211277 print ("[OK] Committed changelog update" )
212278
213279
214- def create_tag (version : str , message : str = None ) -> None :
280+ def create_tag (version : str , message : str | None = None ) -> None :
215281 """Create a git tag for the version."""
216282 tag_name = f"v{ version } "
217283
218284 # Check if tag already exists
219285 try :
220- subprocess .run (
221- ["git" , "rev-parse" , tag_name ],
286+ subprocess .run ( # noqa: S603
287+ [GIT , "rev-parse" , tag_name ],
222288 capture_output = True ,
223289 check = True ,
224290 )
@@ -283,6 +349,10 @@ def main() -> None:
283349 # Check working directory is clean
284350 check_working_directory_clean ()
285351
352+ # Ensure we're not bumping from a stale checkout, which can silently
353+ # drop CHANGELOG.rst entries from releases made on the remote branch.
354+ check_up_to_date_with_remote ()
355+
286356 # Get current version
287357 current_version = get_current_version ()
288358 print (f"Current version: { current_version } " )
0 commit comments