#!/usr/bin/env python3 """Get git changes in the current working directory relative to the remote origin if possible. NOTE: Since this is run as a script, there should be no imports from project files! """ import glob import json import os import subprocess from pathlib import Path def run(cmd: str, cwd: str) -> str: result = subprocess.run( args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd ) byte_content = result.stderr or result.stdout or b'' if result.returncode != 0: raise RuntimeError( f'error_running_cmd:{result.returncode}:{byte_content.decode()}' ) return byte_content.decode().strip() def get_valid_ref(repo_dir: str) -> str | None: refs = [] try: current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir) refs.append(f'origin/{current_branch}') except RuntimeError: pass try: default_branch = ( run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir) .split()[-1] .strip() ) ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")' ref_default_branch = f'origin/{default_branch}' refs.append(ref_non_default_branch) refs.append(ref_default_branch) except RuntimeError: pass # compares with empty tree ref_new_repo = ( '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' ) refs.append(ref_new_repo) # Find a ref that exists... for ref in refs: try: result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir) return result except RuntimeError: # invalid ref - try next continue return None def get_changes_in_repo(repo_dir: str) -> list[dict[str, str]]: # Gets the status relative to the origin default branch - not the same as `git status` ref = get_valid_ref(repo_dir) if not ref: return [] # Get changed files changed_files = run( f'git --no-pager diff --name-status {ref}', repo_dir ).splitlines() changes = [] for line in changed_files: if not line.strip(): raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}') # Handle different output formats from git diff --name-status # Depending on git config, format can be either: # * "A file.txt" # * "A file.txt" # * "R100 old_file.txt new_file.txt" (rename with similarity percentage) parts = line.split() if len(parts) < 2: raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}') status = parts[0].strip() # Handle rename operations (status starts with 'R' followed by similarity percentage) if status.startswith('R') and len(parts) == 3: # Rename: convert to delete (old path) + add (new path) old_path = parts[1].strip() new_path = parts[2].strip() changes.append( { 'status': 'D', 'path': old_path, } ) changes.append( { 'status': 'A', 'path': new_path, } ) continue # Handle copy operations (status starts with 'C' followed by similarity percentage) elif status.startswith('C') and len(parts) == 3: # Copy: only add the new path (original remains) new_path = parts[2].strip() changes.append( { 'status': 'A', 'path': new_path, } ) continue # Handle regular operations (M, A, D, etc.) elif len(parts) == 2: path = parts[1].strip() else: raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}') if status == '??': status = 'A' elif status == '*': status = 'M' # Check for valid single-character status codes if status in {'M', 'A', 'D', 'U'}: changes.append( { 'status': status, 'path': path, } ) else: raise RuntimeError(f'unexpected_status_in_git_diff:{changed_files}') # Get untracked files untracked_files = run( 'git --no-pager ls-files --others --exclude-standard', repo_dir ).splitlines() for path in untracked_files: if path: changes.append({'status': 'A', 'path': path}) return changes def get_git_changes(cwd: str) -> list[dict[str, str]]: git_dirs = { os.path.dirname(f)[2:] for f in glob.glob('./*/.git', root_dir=cwd, recursive=True) } # First try the workspace directory changes = get_changes_in_repo(cwd) # Filter out any changes which are in one of the git directories changes = [ change for change in changes if next( iter(git_dir for git_dir in git_dirs if change['path'].startswith(git_dir)), None, ) is None ] # Add changes from git directories for git_dir in git_dirs: git_dir_changes = get_changes_in_repo(str(Path(cwd, git_dir))) for change in git_dir_changes: change['path'] = git_dir + '/' + change['path'] changes.append(change) changes.sort(key=lambda change: change['path']) return changes if __name__ == '__main__': try: changes = get_git_changes(os.getcwd()) print(json.dumps(changes)) except Exception as e: print(json.dumps({'error': str(e)}))