@@ -219,29 +219,69 @@ def build_preview_markdown(root: Path) -> str:
219219 return "\n " .join (lines ).strip () + "\n "
220220
221221
222- def git_changed_changesets (root : Path , base : str , head : str ) -> list [Path ]:
222+ def relevant_changeset_path (root : Path , path_str : str ) -> Path | None :
223+ path = Path (path_str )
224+ if (
225+ len (path .parts ) == 2
226+ and path .parts [0 ] == ".changesets"
227+ and path .suffix == ".md"
228+ and path .name not in IGNORED_CHANGESET_FILES
229+ ):
230+ return root / path
231+ return None
232+
233+
234+ def git_changed_changesets (root : Path , base : str , head : str ) -> tuple [list [Path ], list [Path ]]:
223235 result = subprocess .run (
224- ["git" , "diff" , "--name-only " , "--diff-filter=AMRT " , f"{ base } ...{ head } " , "--" ],
236+ ["git" , "-c" , " diff.renames=false " , "diff " , "--name-status " , f"{ base } ...{ head } " , "--" ],
225237 cwd = root ,
226238 check = True ,
227239 capture_output = True ,
228240 text = True ,
229241 )
230- changed_files = []
231- for raw_path in result .stdout .splitlines ():
232- path = Path (raw_path )
233- if (
234- len (path .parts ) == 2
235- and path .parts [0 ] == ".changesets"
236- and path .suffix == ".md"
237- and path .name not in IGNORED_CHANGESET_FILES
238- ):
239- changed_files .append (root / path )
240- return sorted (changed_files )
242+ changed_files : list [Path ] = []
243+ deleted_files : list [Path ] = []
244+
245+ for raw_line in result .stdout .splitlines ():
246+ if not raw_line .strip ():
247+ continue
248+
249+ parts = raw_line .split ("\t " )
250+ status = parts [0 ]
251+
252+ if status .startswith (("R" , "C" )):
253+ if len (parts ) < 3 :
254+ continue
255+ old_path = relevant_changeset_path (root , parts [1 ])
256+ new_path = relevant_changeset_path (root , parts [2 ])
257+ if old_path is not None and new_path is None :
258+ deleted_files .append (old_path )
259+ elif new_path is not None :
260+ changed_files .append (new_path )
261+ continue
262+
263+ if len (parts ) < 2 :
264+ continue
265+
266+ path = relevant_changeset_path (root , parts [1 ])
267+ if path is None :
268+ continue
269+ if status == "D" :
270+ deleted_files .append (path )
271+ else :
272+ changed_files .append (path )
273+
274+ return sorted (set (changed_files )), sorted (set (deleted_files ))
241275
242276
243277def validate_pr_changeset (root : Path , base : str , head : str ) -> int :
244- changed_changesets = git_changed_changesets (root , base , head )
278+ changed_changesets , deleted_changesets = git_changed_changesets (root , base , head )
279+ if deleted_changesets :
280+ joined = ", " .join (str (path .relative_to (root )) for path in deleted_changesets )
281+ raise ValueError (
282+ "PRs must not delete changeset files under .changesets/. "
283+ f"Deleted changesets: { joined } "
284+ )
245285 if len (changed_changesets ) != 1 :
246286 joined = ", " .join (str (path .relative_to (root )) for path in changed_changesets ) or "none"
247287 raise ValueError (
@@ -268,32 +308,64 @@ def build_changelog_entry(version: str, changesets: list[Changeset]) -> str:
268308 for item in items :
269309 lines .append (f"- { item .summary } " )
270310 if item .details :
271- lines .append (f" { item .details } " )
311+ for detail_line in item .details .splitlines ():
312+ lines .append (f" { detail_line } " if detail_line else " " )
272313 lines .append ("" )
273314
274315 return "\n " .join (lines ).rstrip ()
275316
276317
277- def prepend_changelog_entry (root : Path , entry : str ) -> None :
278- changelog_file = changelog_path (root )
279- existing = changelog_file .read_text (encoding = "utf-8" )
318+ def render_changelog_with_entry (existing : str , entry : str ) -> str :
280319 heading_match = re .search (r"^## " , existing , flags = re .MULTILINE )
281320 if heading_match :
282321 insertion_point = heading_match .start ()
283- new_text = existing [:insertion_point ].rstrip () + "\n \n " + entry + "\n \n " + existing [insertion_point :].lstrip ()
322+ return (
323+ existing [:insertion_point ].rstrip ()
324+ + "\n \n "
325+ + entry
326+ + "\n \n "
327+ + existing [insertion_point :].lstrip ()
328+ )
284329 else :
285- new_text = existing .rstrip () + "\n \n " + entry + "\n "
286- changelog_file .write_text (new_text , encoding = "utf-8" )
330+ return existing .rstrip () + "\n \n " + entry + "\n "
287331
288332
289- def archive_changesets (root : Path , version : str , changesets : list [Changeset ]) -> None :
333+ def archive_changesets (root : Path , version : str , changesets : list [Changeset ]) -> list [ tuple [ Path , Path ]] :
290334 archive_directory = changeset_dir (root ) / "archive" / f"v{ version } "
291335 archive_directory .mkdir (parents = True , exist_ok = True )
292- for changeset in changesets :
293- destination = archive_directory / changeset .path .name
336+ destinations = [(changeset .path , archive_directory / changeset .path .name ) for changeset in changesets ]
337+
338+ for _ , destination in destinations :
294339 if destination .exists ():
295340 raise FileExistsError (f"Archive destination already exists: { destination } " )
296- shutil .move (str (changeset .path ), destination )
341+
342+ moved_changesets : list [tuple [Path , Path ]] = []
343+ try :
344+ for changeset in changesets :
345+ destination = archive_directory / changeset .path .name
346+ shutil .move (str (changeset .path ), destination )
347+ moved_changesets .append ((changeset .path , destination ))
348+ except Exception :
349+ rollback_archived_changesets (root , moved_changesets )
350+ raise
351+ return moved_changesets
352+
353+
354+ def rollback_archived_changesets (root : Path , moved_changesets : list [tuple [Path , Path ]]) -> None :
355+ archive_root = changeset_dir (root ) / "archive"
356+ for source , destination in reversed (moved_changesets ):
357+ if destination .exists ():
358+ shutil .move (str (destination ), source )
359+
360+ current = destination .parent
361+ while current != archive_root .parent :
362+ try :
363+ current .rmdir ()
364+ except OSError :
365+ break
366+ current = current .parent
367+ if current == archive_root .parent :
368+ break
297369
298370
299371def apply_release (root : Path ) -> str :
@@ -309,9 +381,25 @@ def apply_release(root: Path) -> str:
309381 "Pending changesets exist, but all are marked 'none'; nothing to release yet"
310382 )
311383
312- version_path (root ).write_text (computed_next_version + "\n " , encoding = "utf-8" )
313- prepend_changelog_entry (root , build_changelog_entry (computed_next_version , changesets ))
314- archive_changesets (root , computed_next_version , changesets )
384+ version_file = version_path (root )
385+ changelog_file = changelog_path (root )
386+ original_version = version_file .read_text (encoding = "utf-8" )
387+ original_changelog = changelog_file .read_text (encoding = "utf-8" )
388+ updated_changelog = render_changelog_with_entry (
389+ original_changelog , build_changelog_entry (computed_next_version , changesets )
390+ )
391+
392+ moved_changesets : list [tuple [Path , Path ]] = []
393+ try :
394+ moved_changesets = archive_changesets (root , computed_next_version , changesets )
395+ changelog_file .write_text (updated_changelog , encoding = "utf-8" )
396+ version_file .write_text (computed_next_version + "\n " , encoding = "utf-8" )
397+ except Exception :
398+ if moved_changesets :
399+ rollback_archived_changesets (root , moved_changesets )
400+ changelog_file .write_text (original_changelog , encoding = "utf-8" )
401+ version_file .write_text (original_version , encoding = "utf-8" )
402+ raise
315403 return computed_next_version
316404
317405
0 commit comments