""" CMakeLists.txt FetchContent Dependency Updater This script: 1. Searches upward for all CMakeLists.txt files containing FetchContent 2. Extracts current git hashes/tags/branches 3. Queries remote repositories for latest versions 4. Shows available updates and prompts for confirmation 5. Updates CMakeLists.txt files with new versions """ import re import subprocess import sys import urllib.request import json from pathlib import Path from typing import List, Optional, Tuple def parse_semver(version_string: str) -> Tuple[int, int, int]: """ Parse a semantic version string. Converts version strings like 'v1.2.3' or '1.2.3' into (major, minor, patch) """ # Remove 'v' prefix if present clean = version_string.lstrip('v') # Split by dots and take first 3 components parts = clean.split('.') major = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else 0 minor = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 # Handle patch with possible suffix (e.g., "3-rc1") patch = 0 if len(parts) > 2: # Take only numeric part before dash patch_str = parts[2].split('-')[0] patch = int(patch_str) if patch_str.isdigit() else 0 return (major, minor, patch) class FetchContentDependency: """Represents a single FetchContent_Declare dependency""" def __init__( self, name: str, git_repo: str, git_tag: str, file_path: Path, line_start: int, line_end: int, full_text: str ): self.name = name self.git_repo = git_repo self.git_tag = git_tag self.file_path = file_path self.line_start = line_start self.line_end = line_end self.full_text = full_text self.latest_version: Optional[str] = None self.update_available = False def __repr__(self): return f"FetchContentDependency({self.name}, {self.git_tag})" def extract_subdirectories(cmake_file: Path, content: str) -> List[Path]: """Extract add_subdirectory() calls from a CMakeLists.txt file content""" subdirs = [] try: # Match add_subdirectory(path) with/without quotes pattern = r'add_subdirectory\s*\(\s*(["\']?)([^)"\']+)\1' for match in re.finditer(pattern, content, re.IGNORECASE): subdir_name = match.group(2).strip() # Resolve relative to the CMakeLists.txt directory subdir_path = (cmake_file.parent / subdir_name).resolve() subdirs.append(subdir_path) except Exception as e: print( f"Warning: Error parsing subdirectories in {cmake_file}: {e}", file=sys.stderr ) return subdirs def find_cmake_files_recursive(cmake_dir: Path, visited: set) -> List[Path]: """ Recursively find CMakeLists.txt files. Follows add_subdirectory() calls to discover all CMake files. """ cmake_files = [] # Avoid infinite loops cmake_dir = cmake_dir.resolve() if cmake_dir in visited: return cmake_files visited.add(cmake_dir) # Check if this directory has a CMakeLists.txt cmake_path = cmake_dir / "CMakeLists.txt" if not cmake_path.exists(): return cmake_files # Read the file once try: content = cmake_path.read_text() except Exception as e: print(f"Warning: Error reading {cmake_path}: {e}", file=sys.stderr) return cmake_files # Check if it contains FetchContent (if so, add to results) if "FetchContent" in content: cmake_files.append(cmake_path) # Always recurse into subdirectories # (subdirectories might have FetchContent) subdirs = extract_subdirectories(cmake_path, content) for subdir in subdirs: cmake_files.extend(find_cmake_files_recursive(subdir, visited)) return cmake_files def find_cmake_files(start_dir: Path = None) -> List[Path]: """ Search upward for root CMakeLists.txt. Then recursively find all via add_subdirectory(). """ if start_dir is None: start_dir = Path.cwd() current = start_dir.resolve() # Search upward to find the root CMakeLists.txt root_cmake = None while True: cmake_path = current / "CMakeLists.txt" if cmake_path.exists(): root_cmake = cmake_path # Stop at filesystem root if current.parent == current: break current = current.parent # If we found a root CMakeLists.txt, start recursive search from there if root_cmake: visited = set() files = find_cmake_files_recursive(root_cmake.parent, visited) return sorted(set(files)) return [] def parse_fetchcontent(cmake_file: Path) -> List[FetchContentDependency]: """Parse FetchContent_Declare blocks from CMakeLists.txt""" content = cmake_file.read_text() lines = content.split('\n') dependencies = [] # Pattern to match FetchContent_Declare blocks # Extracts name, GIT_REPOSITORY and GIT_TAG i = 0 while i < len(lines): line = lines[i] # Look for FetchContent_Declare( if "FetchContent_Declare" in line: start_line = i # Find the closing parenthesis paren_count = line.count('(') - line.count(')') block_lines = [line] j = i + 1 while j < len(lines) and paren_count > 0: block_lines.append(lines[j]) paren_count += lines[j].count('(') - lines[j].count(')') j += 1 end_line = j - 1 full_block = '\n'.join(block_lines) # Extract dependency name name_match = re.search( r'FetchContent_Declare\s*\(\s*(\S+)', full_block ) if not name_match: i = j continue dep_name = name_match.group(1) # Extract GIT_REPOSITORY repo_match = re.search(r'GIT_REPOSITORY\s+(\S+)', full_block) # Extract GIT_TAG tag_match = re.search(r'GIT_TAG\s+(\S+)', full_block) if repo_match and tag_match: git_repo = repo_match.group(1) git_tag = tag_match.group(1) dep = FetchContentDependency( name=dep_name, git_repo=git_repo, git_tag=git_tag, file_path=cmake_file, line_start=start_line, line_end=end_line, full_text=full_block ) dependencies.append(dep) i = j else: i += 1 return dependencies def run_git_command(args: List[str]) -> Optional[str]: """Run a git command and return output, or None on error""" try: result = subprocess.run( args, capture_output=True, text=True, timeout=30, check=True ) return result.stdout.strip() except subprocess.CalledProcessError as e: print( f"Warning: Error running git command: {' '.join(args)}", file=sys.stderr ) print(f" {e.stderr}", file=sys.stderr) return None except subprocess.TimeoutExpired: print( f"Warning: Timeout running git command: {' '.join(args)}", file=sys.stderr ) return None def get_latest_commit_hash(repo_url: str, branch: str) -> Optional[str]: """Get the latest commit hash for a branch using git ls-remote""" output = run_git_command( ['git', 'ls-remote', repo_url, f'refs/heads/{branch}'] ) if not output: print( f"Warning: Branch '{branch}' not found in {repo_url}", file=sys.stderr ) return None # Output format: "commit_hash\trefs/heads/branch" commit_hash = output.split()[0] return commit_hash def extract_github_info(repo_url: str) -> Optional[Tuple[str, str]]: """Extract owner/repo from GitHub URL""" # Handle both https and git@ URLs patterns = [ r'github\.com[:/]([^/]+)/([^/.]+?)(?:\.git)?$', ] for pattern in patterns: match = re.search(pattern, repo_url) if match: return match.group(1), match.group(2) return None def get_latest_github_release(repo_url: str) -> Optional[str]: """Get the latest release tag from GitHub API""" github_info = extract_github_info(repo_url) if not github_info: return None owner, repo = github_info api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" try: req = urllib.request.Request(api_url) req.add_header('Accept', 'application/vnd.github.v3+json') with urllib.request.urlopen(req, timeout=10) as response: data = json.loads(response.read()) return data.get('tag_name') except urllib.error.HTTPError as e: if e.code == 404: # No releases found, try tags return get_latest_github_tag(repo_url) print( f"Warning: Error fetching GitHub release for {owner}/{repo}: {e}", file=sys.stderr ) return None except Exception as e: print(f"Warning: Error fetching GitHub release: {e}", file=sys.stderr) return None def get_latest_github_tag(repo_url: str) -> Optional[str]: """Get the latest tag from GitHub API""" github_info = extract_github_info(repo_url) if not github_info: return None owner, repo = github_info api_url = f"https://api.github.com/repos/{owner}/{repo}/tags" try: req = urllib.request.Request(api_url) req.add_header('Accept', 'application/vnd.github.v3+json') with urllib.request.urlopen(req, timeout=10) as response: data = json.loads(response.read()) if data: # Filter to semantic version tags and find the latest version_tags = [] for tag in data: tag_name = tag['name'] try: # Try to parse as semantic version v = parse_semver(tag_name) version_tags.append((v, tag_name)) except Exception: pass if version_tags: # Sort by version and return the latest version_tags.sort(reverse=True) return version_tags[0][1] # If no semantic versions, return the first tag return data[0]['name'] return None except Exception as e: print(f"Warning: Error fetching GitHub tags: {e}", file=sys.stderr) return None def is_commit_hash(git_tag: str) -> bool: """Check if a git tag looks like a commit hash""" return bool(re.match(r'^[0-9a-f]{7,40}$', git_tag)) def is_semantic_version(git_tag: str) -> bool: """Check if a git tag is a semantic version""" try: parse_semver(git_tag) return True except Exception: return False def compare_versions(current: str, latest: str) -> bool: """Compare two version strings, return True if latest is newer""" try: current_v = parse_semver(current) latest_v = parse_semver(latest) return latest_v > current_v except Exception: return current != latest def fetch_latest_version(dep: FetchContentDependency) -> None: """Fetch the latest version for a dependency""" git_tag = dep.git_tag git_repo = dep.git_repo # Case 1: Branch name (main, master, develop, etc.) if git_tag in ['main', 'master', 'develop', 'trunk']: latest = get_latest_commit_hash(git_repo, git_tag) if latest: dep.latest_version = latest # Always update branches to commit hash dep.update_available = True return # Case 2: Commit hash - check if it's the latest on main/master # IMPORTANT: Check this BEFORE semantic version to avoid false positives if is_commit_hash(git_tag): # Try to get latest from main first, then master for branch in ['main', 'master']: latest = get_latest_commit_hash(git_repo, branch) if latest: dep.latest_version = latest dep.update_available = (git_tag != latest) return return # Case 3: Semantic version tag if is_semantic_version(git_tag): latest = get_latest_github_release(git_repo) if latest: dep.latest_version = latest dep.update_available = compare_versions(git_tag, latest) return # Case 4: Unknown format - treat as branch name latest = get_latest_commit_hash(git_repo, git_tag) if latest: dep.latest_version = latest # Convert branch to commit hash dep.update_available = True else: print( f"Warning: Could not determine version type for " f"{dep.name}: {git_tag}", file=sys.stderr ) def print_updates_table(dependencies: List[FetchContentDependency]) -> None: """Print a formatted table of available updates""" updates = [dep for dep in dependencies if dep.update_available] if not updates: print("\nNo updates available. All dependencies are up to date.") return print(f"\n{len(updates)} update(s) available:\n") # Calculate column widths name_width = max(len(dep.name) for dep in updates) current_width = max(len(dep.git_tag) for dep in updates) latest_width = max(len(dep.latest_version or '') for dep in updates) file_width = max(len(str(dep.file_path.name)) for dep in updates) # Print header header = ( f"{'Name':<{name_width}} {'Current':<{current_width}} " f"{'Latest':<{latest_width}} {'File':<{file_width}}" ) print(header) print('-' * len(header)) # Print rows for dep in updates: current = dep.git_tag latest = dep.latest_version or '?' filename = dep.file_path.name # Truncate long hashes for display if len(current) > 12: current_display = current[:12] + '...' else: current_display = current if len(latest) > 12: latest_display = latest[:12] + '...' else: latest_display = latest print( f"{dep.name:<{name_width}} {current_display:<{current_width}} " f"{latest_display:<{latest_width}} {filename:<{file_width}}" ) def update_cmake_file(dep: FetchContentDependency) -> None: """Update a single dependency in its CMakeLists.txt file""" content = dep.file_path.read_text() # Create the updated version of this specific FetchContent block old_tag_line = f"GIT_TAG {dep.git_tag}" new_tag_line = f"GIT_TAG {dep.latest_version}" if old_tag_line not in dep.full_text: print( f"Warning: Could not find exact match for {dep.name} tag line", file=sys.stderr ) return # Update only this dependency's block new_block = dep.full_text.replace(old_tag_line, new_tag_line, 1) # Replace the old block with the new block in the file content # Use replace with count=1 to only replace the first occurrence if dep.full_text not in content: print( f"Warning: Could not find block for {dep.name} in file", file=sys.stderr ) return new_content = content.replace(dep.full_text, new_block, 1) if new_content == content: print(f"Warning: No changes made for {dep.name}", file=sys.stderr) return # Write back dep.file_path.write_text(new_content) print(f"Updated {dep.name} in {dep.file_path.name}") def main(): """Main entry point""" print("Searching for CMakeLists.txt files with FetchContent...") cmake_files = find_cmake_files() if not cmake_files: print("No CMakeLists.txt files with FetchContent found.") return print(f"Found {len(cmake_files)} CMakeLists.txt file(s):") for f in cmake_files: print(f" - {f}") # Parse all dependencies all_deps = [] for cmake_file in cmake_files: deps = parse_fetchcontent(cmake_file) all_deps.extend(deps) print(f"\nFound {len(all_deps)} FetchContent dependencies") # Fetch latest versions print("\nChecking for updates...") for dep in all_deps: print(f" Checking {dep.name} ({dep.git_repo})...", end=' ') fetch_latest_version(dep) if dep.update_available: print("UPDATE AVAILABLE") elif dep.latest_version: print("up to date") else: print("SKIPPED (error)") # Show updates table print_updates_table(all_deps) # Get confirmation updates = [dep for dep in all_deps if dep.update_available] if not updates: return print() response = input( f"Apply {len(updates)} update(s)? [y/N]: " ).strip().lower() if response != 'y': print("Aborted.") return # Apply updates print("\nApplying updates...") for dep in updates: update_cmake_file(dep) print(f"\nSuccessfully updated {len(updates)} dependencies.") print("Please review the changes and test your build.") if __name__ == "__main__": main()