feat(binder): add orphaned git tag detection and cleanup

Solves critical version management issue where failed publishes
leave orphaned git tags that cause version numbering problems.

New features:
- Automatic detection of git tags without GitHub releases
- Interactive cleanup options (clean all, manual selection, etc.)
- Smart version calculation that accounts for orphaned tags
- New 'check-tags' command for standalone tag management
- Detailed tag information display (creation date, commit info)
- Safe cleanup with confirmation prompts

Prevents version increment issues when previous publishes fail
after creating git tags but before completing GitHub releases.
This commit is contained in:
Vijay Janapa Reddi
2025-08-05 20:26:05 -04:00
parent c090f1652f
commit caf887d73c

234
binder
View File

@@ -1420,7 +1420,7 @@ class BookBinder:
# Helper methods
def _get_current_version(self):
"""Get current version from git tags"""
"""Get current version from git tags, handling orphaned tags"""
try:
result = subprocess.run(['git', 'tag', '--list', 'v*'],
capture_output=True, text=True, cwd=self.root_dir)
@@ -1429,16 +1429,192 @@ class BookBinder:
if not tags:
return "v0.0.0"
# Find the highest version
# Find all version tags, sorted by version
versions = [tag for tag in tags if tag.startswith('v')]
if not versions:
return "v0.0.0"
return max(versions, key=lambda v: [int(x) for x in v[1:].split('.')])
# Sort versions to get the latest
sorted_versions = sorted(versions, key=lambda v: [int(x) for x in v[1:].split('.')])
latest_version = sorted_versions[-1]
# Check for orphaned tags (tags without GitHub releases)
orphaned_tags = self._find_orphaned_tags(sorted_versions)
if orphaned_tags:
# Handle orphaned tags before proceeding
handled_version = self._handle_orphaned_tags(orphaned_tags, latest_version)
if handled_version:
return handled_version
return latest_version
except Exception:
return "v0.0.0"
def _find_orphaned_tags(self, versions):
"""Find git tags that don't have corresponding GitHub releases"""
orphaned_tags = []
try:
# Check if GitHub CLI is available
subprocess.run(['gh', '--version'], capture_output=True, check=True)
# Get list of GitHub releases
result = subprocess.run(['gh', 'release', 'list', '--limit', '100'],
capture_output=True, text=True, cwd=self.root_dir)
if result.returncode == 0:
# Parse release list to get tag names
release_lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
release_tags = set()
for line in release_lines:
if line.strip():
# GitHub CLI output format: "title tag status date"
parts = line.split('\t')
if len(parts) >= 2:
tag = parts[1].strip()
release_tags.add(tag)
# Find tags without releases
for version in versions:
if version not in release_tags:
orphaned_tags.append(version)
except (subprocess.CalledProcessError, FileNotFoundError):
# GitHub CLI not available, can't check for orphaned tags
pass
return orphaned_tags
def _handle_orphaned_tags(self, orphaned_tags, latest_version):
"""Handle orphaned tags with user interaction"""
console.print()
console.print("[bold yellow]⚠️ Orphaned Git Tags Detected[/bold yellow]")
console.print("[dim]These tags exist but don't have corresponding GitHub releases:[/dim]")
console.print()
for tag in orphaned_tags:
console.print(f"[yellow] 🏷️ {tag}[/yellow]")
console.print()
console.print("[bold white]💡 This usually happens when a previous publish failed after creating the tag[/bold white]")
console.print("[dim]but before completing the GitHub release. These orphaned tags can cause[/dim]")
console.print("[dim]version numbering issues and should be cleaned up.[/dim]")
console.print()
console.print("[bold white]Options:[/bold white]")
console.print("[green] 1. clean[/green] - Delete all orphaned tags and use the latest valid version")
console.print("[yellow] 2. ignore[/yellow] - Continue with current latest tag (may cause issues)")
console.print("[blue] 3. manual[/blue] - Let me choose which tags to delete [default]")
console.print("[red] 4. cancel[/red] - Cancel publishing to handle manually")
console.print()
console.print("[white]How would you like to handle orphaned tags? [1/2/3/4] [default: 3]: [/white]", end="")
choice = input().strip()
if not choice:
choice = "3"
if choice == "1":
# Clean all orphaned tags
console.print("[purple]🧹 Cleaning all orphaned tags...[/purple]")
for tag in orphaned_tags:
self._delete_version(tag)
console.print(f"[green] ✅ Deleted orphaned tag: {tag}[/green]")
# Find the latest non-orphaned version
# Get all tags again and exclude the orphaned ones
try:
result = subprocess.run(['git', 'tag', '--list', 'v*'],
capture_output=True, text=True, cwd=self.root_dir)
remaining_tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
remaining_versions = [tag for tag in remaining_tags if tag.startswith('v')]
if remaining_versions:
return max(remaining_versions, key=lambda v: [int(x) for x in v[1:].split('.')])
else:
return "v0.0.0"
except:
return "v0.0.0"
elif choice == "2":
# Continue with latest tag
console.print("[yellow]⚠️ Continuing with potentially orphaned tag. This may cause issues.[/yellow]")
return latest_version
elif choice == "3":
# Manual selection
return self._manual_orphaned_tag_cleanup(orphaned_tags, latest_version)
else: # choice == "4" or invalid
console.print("[blue] Publishing cancelled. Please handle orphaned tags manually.[/blue]")
console.print("[dim]You can delete tags with: git tag -d <tag> && git push origin --delete <tag>[/dim]")
return None
def _manual_orphaned_tag_cleanup(self, orphaned_tags, latest_version):
"""Manual orphaned tag cleanup with individual choices"""
console.print()
console.print("[bold blue]🔧 Manual Orphaned Tag Cleanup[/bold blue]")
console.print("[dim]Review each orphaned tag and decide whether to delete it:[/dim]")
console.print()
deleted_tags = []
for tag in sorted(orphaned_tags, key=lambda v: [int(x) for x in v[1:].split('.')], reverse=True):
console.print(f"[bold white]Tag: {tag}[/bold white]")
# Show when this tag was created
try:
result = subprocess.run(['git', 'log', '--format=%ai %s', '-1', tag],
capture_output=True, text=True, cwd=self.root_dir)
if result.returncode == 0 and result.stdout.strip():
tag_info = result.stdout.strip()
console.print(f"[dim] Created: {tag_info}[/dim]")
except:
pass
console.print("[bold white]Options:[/bold white]")
console.print("[red] d/delete[/red] - Delete this orphaned tag")
console.print("[green] k/keep[/green] - Keep this tag (may cause version issues)")
console.print("[blue] s/skip[/blue] - Skip for now [default]")
console.print(f"[yellow]Delete orphaned tag {tag}? [d/k/s] [default: s]: [/yellow]", end="")
choice = input().strip().lower()
if not choice:
choice = 's'
if choice in ['d', 'delete']:
console.print(f"[purple]🗑️ Deleting orphaned tag {tag}...[/purple]")
self._delete_version(tag)
deleted_tags.append(tag)
console.print(f"[green] ✅ Deleted: {tag}[/green]")
elif choice in ['k', 'keep']:
console.print(f"[yellow] ⚠️ Keeping potentially problematic tag: {tag}[/yellow]")
else:
console.print(f"[blue] ⏭️ Skipping: {tag}[/blue]")
console.print()
if deleted_tags:
console.print(f"[green]✅ Cleanup complete. Deleted {len(deleted_tags)} orphaned tags.[/green]")
# Recalculate the latest version after cleanup
try:
result = subprocess.run(['git', 'tag', '--list', 'v*'],
capture_output=True, text=True, cwd=self.root_dir)
remaining_tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
remaining_versions = [tag for tag in remaining_tags if tag.startswith('v')]
if remaining_versions:
return max(remaining_versions, key=lambda v: [int(x) for x in v[1:].split('.')])
else:
return "v0.0.0"
except:
return "v0.0.0"
else:
console.print("[blue] No tags were deleted.[/blue]")
return latest_version
def _calculate_next_version(self, current_version, release_type):
"""Calculate next version based on release type"""
try:
@@ -2240,6 +2416,7 @@ Please format as:
mgmt_table.add_row("clean", "Clean configs & artifacts", "./binder clean")
mgmt_table.add_row("check", "Check for build artifacts", "./binder check")
mgmt_table.add_row("check-tags", "Check for orphaned git tags", "./binder check-tags")
mgmt_table.add_row("switch <format>", "Switch config", "./binder switch pdf")
mgmt_table.add_row("status", "Show current status", "./binder status")
mgmt_table.add_row("list", "List chapters", "./binder list")
@@ -3013,6 +3190,54 @@ Please format as:
except Exception as e:
console.print(f"[red]❌ Test failed: {e}[/red]")
def check_orphaned_tags(self):
"""Check for orphaned git tags without GitHub releases"""
self.show_banner()
console.print("[bold blue]🔍 Checking for Orphaned Git Tags[/bold blue]")
console.print("[dim]Scanning for git tags that don't have corresponding GitHub releases...[/dim]")
console.print()
try:
# Get all version tags
result = subprocess.run(['git', 'tag', '--list', 'v*'],
capture_output=True, text=True, cwd=self.root_dir)
tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
if not tags:
console.print("[blue] No version tags found in repository[/blue]")
return
versions = [tag for tag in tags if tag.startswith('v')]
if not versions:
console.print("[blue] No version tags found in repository[/blue]")
return
console.print(f"[blue] Found {len(versions)} version tags[/blue]")
# Check for orphaned tags
orphaned_tags = self._find_orphaned_tags(versions)
if not orphaned_tags:
console.print("[green]✅ No orphaned tags detected![/green]")
console.print("[dim]All git tags have corresponding GitHub releases[/dim]")
# Show current version info
latest_version = max(versions, key=lambda v: [int(x) for x in v[1:].split('.')])
console.print(f"[blue] Latest version: {latest_version}[/blue]")
return
# Handle orphaned tags
console.print(f"[yellow]⚠️ Found {len(orphaned_tags)} orphaned tags[/yellow]")
handled_version = self._handle_orphaned_tags(orphaned_tags, versions[-1])
if handled_version:
console.print(f"[green]✅ Cleanup complete. Current version: {handled_version}[/green]")
else:
console.print("[blue] Tag check cancelled. No changes made.[/blue]")
except Exception as e:
console.print(f"[red]❌ Error checking tags: {e}[/red]")
def main():
binder = BookBinder()
@@ -3129,6 +3354,9 @@ def main():
elif command == "setup":
binder.setup_environment()
elif command == "check-tags":
binder.check_orphaned_tags()
else:
console.print(f"[red]❌ Unknown command: {command}[/red]")
console.print("[yellow]💡 Use './binder help' to see available commands[/yellow]")