Files
profilarr/backend/app/git/status/incoming_changes.py
Sam Chau 6ff0e79a28 feature: regex patterns (#10)
- add new regex patterns, matched using PCRE2, with case insensitivity
- name, description, pattern, tags
- add unit tests, attempt to highlight matches
2025-02-05 16:09:59 +10:30

470 lines
16 KiB
Python

# git/status/incoming_changes.py
import os
import logging
import yaml
from git import GitCommandError
from .utils import determine_type, parse_commit_message, extract_data_from_yaml
logger = logging.getLogger(__name__)
def check_merge_conflict(repo, branch, file_path):
"""Checks if an incoming change will conflict with local changes."""
try:
has_changes = False
# Check uncommitted changes
status = repo.git.status('--porcelain', file_path).strip()
if status:
status_code = status[:2] if len(status) >= 2 else ''
has_changes = 'M' in status_code or 'A' in status_code or 'D' in status_code
# Check committed changes not in remote
try:
merge_base = repo.git.merge_base('HEAD',
f'origin/{branch}').strip()
committed_changes = repo.git.log(f'{merge_base}..HEAD',
'--',
file_path,
ignore_missing=True).strip()
has_changes = has_changes or bool(committed_changes)
except GitCommandError as e:
logger.warning(f"Error checking committed changes: {str(e)}")
if has_changes:
try:
merge_test = repo.git.merge_tree('--write-tree', 'HEAD',
f'origin/{branch}')
return any(
line.startswith('<<<<<<< ')
for line in merge_test.splitlines() if file_path in line)
except GitCommandError as e:
logger.warning(
f"Merge tree test failed, assuming conflict: {str(e)}")
return True
return False
except Exception as e:
logger.error(
f"Error checking merge conflict for {file_path}: {str(e)}")
return False
def get_file_data(repo, file_path, ref):
try:
content = repo.git.show(f'{ref}:{file_path}')
return yaml.safe_load(content)
except GitCommandError:
logger.warning(
f"Failed to retrieve content for file: {file_path} at {ref}")
return None
def get_incoming_changes(repo, branch):
incoming_changes = []
try:
diff_index = repo.git.diff(f'HEAD...origin/{branch}',
'--name-only').split('\n')
untracked = repo.git.ls_files('--others',
'--exclude-standard').split('\n')
changed_files = list(filter(None, set(diff_index + untracked)))
except GitCommandError as e:
logger.error(f"Error getting changed files: {str(e)}")
return []
for file_path in changed_files:
if not file_path:
continue
try:
local_data = get_file_data(repo, file_path, 'HEAD')
remote_data = get_file_data(repo, file_path, f'origin/{branch}')
if local_data == remote_data:
continue
will_conflict = check_merge_conflict(repo, branch, file_path)
try:
raw_commit_message = repo.git.show(f'HEAD...origin/{branch}',
'--format=%B', '-s', '--',
file_path).strip()
commit_message = parse_commit_message(raw_commit_message)
except GitCommandError:
commit_message = {
"body": "",
"footer": "",
"scope": "",
"subject": "Unable to retrieve commit message",
"type": ""
}
if not local_data and remote_data:
status = 'New'
local_name = remote_data.get('name')
incoming_name = None
changes = [{'key': 'File', 'change': 'added'}]
else:
status = 'Modified'
local_name = local_data.get(
'name') if local_data else os.path.basename(file_path)
incoming_name = remote_data.get(
'name') if remote_data else None
if file_path.startswith('profiles/'):
changes = compare_quality_profile(local_data, remote_data)
else:
changes = compare_generic(local_data, remote_data)
if not changes:
continue
file_type = determine_type(file_path)
file_id = remote_data.get('id') if remote_data else None
incoming_changes.append({
'commit_message': commit_message,
'deleted': False,
'file_path': file_path,
'id': file_id,
'modified': True,
'local_name': local_name,
'incoming_name': incoming_name,
'staged': False,
'status': status,
'type': file_type,
'changes': changes,
'will_conflict': will_conflict
})
except Exception as e:
logger.error(
f"Error processing incoming change for {file_path}: {str(e)}")
continue
logger.info(f"Found {len(incoming_changes)} incoming changes")
return incoming_changes
def compare_quality_profile(local_data, remote_data):
"""Compare quality profile specific changes"""
if local_data is None and remote_data is not None:
return [{'key': 'File', 'change': 'added'}]
if local_data is not None and remote_data is None:
return [{'key': 'File', 'change': 'deleted'}]
changes = []
# Simple fields with consistent capitalization
simple_fields = {
'name': 'Name',
'description': 'Description',
'language': 'Language',
'minCustomFormatScore': 'Minimum Custom Format Score',
'minScoreIncrement': 'Minimum Score Increment',
'upgradeUntilScore': 'Upgrade Until Score',
'upgradesAllowed': 'Upgrades Allowed'
}
for field, display_name in simple_fields.items():
local_value = local_data.get(field)
remote_value = remote_data.get(field)
if local_value != remote_value:
changes.append({
'key': display_name,
'change': 'modified',
'from': local_value,
'to': remote_value
})
# Compare qualities
if local_data.get('qualities') != remote_data.get('qualities'):
changes.extend(
compare_qualities(local_data.get('qualities', []),
remote_data.get('qualities', [])))
# Compare profile-specific custom formats
if local_data.get('custom_formats') != remote_data.get('custom_formats'):
changes.extend(
compare_profile_formats(local_data.get('custom_formats', []),
remote_data.get('custom_formats', [])))
# Compare tags
if local_data.get('tags') != remote_data.get('tags'):
changes.extend(
compare_tags(local_data.get('tags', []),
remote_data.get('tags', [])))
# Compare upgrade_until
if local_data.get('upgrade_until') != remote_data.get('upgrade_until'):
changes.extend(
compare_upgrade_until(local_data.get('upgrade_until', {}),
remote_data.get('upgrade_until', {})))
return changes
def compare_qualities(local_qualities, remote_qualities):
"""Compare quality groups and their qualities"""
if not local_qualities and not remote_qualities:
return []
changes = []
# Create lookup dictionaries
local_dict = {quality.get('name'): quality for quality in local_qualities}
remote_dict = {
quality.get('name'): quality
for quality in remote_qualities
}
# Find added/removed qualities
local_names = set(local_dict.keys())
remote_names = set(remote_dict.keys())
# Track additions
for name in (remote_names - local_names):
changes.append({
'key': 'Quality Group',
'change': 'added',
'value': name
})
# Track removals
for name in (local_names - remote_names):
changes.append({
'key': 'Quality Group',
'change': 'removed',
'value': name
})
# Compare common qualities
for name in (local_names & remote_names):
local_quality = local_dict[name]
remote_quality = remote_dict[name]
# Compare description
if local_quality.get('description') != remote_quality.get(
'description'):
changes.append({
'key': f'Quality Group: {name}: Description',
'change': 'modified',
'from': local_quality.get('description'),
'to': remote_quality.get('description')
})
# Compare nested qualities
local_nested = {
q.get('name'): q
for q in local_quality.get('qualities', [])
}
remote_nested = {
q.get('name'): q
for q in remote_quality.get('qualities', [])
}
nested_local = set(local_nested.keys())
nested_remote = set(remote_nested.keys())
for nested_name in (nested_remote - nested_local):
changes.append({
'key': f'Quality Group: {name}: Quality',
'change': 'added',
'value': nested_name
})
for nested_name in (nested_local - nested_remote):
changes.append({
'key': f'Quality Group: {name}: Quality',
'change': 'removed',
'value': nested_name
})
return changes
def compare_profile_formats(local_formats, remote_formats):
"""Compare custom formats within a quality profile"""
if not local_formats and not remote_formats:
return []
changes = []
# Create lookup dictionaries
local_dict = {fmt.get('name'): fmt.get('score') for fmt in local_formats}
remote_dict = {fmt.get('name'): fmt.get('score') for fmt in remote_formats}
local_names = set(local_dict.keys())
remote_names = set(remote_dict.keys())
# Track additions
for name in (remote_names - local_names):
changes.append({
'key': 'Custom Format',
'change': 'added',
'value': {
'name': name,
'score': remote_dict[name]
}
})
# Track removals
for name in (local_names - remote_names):
changes.append({
'key': 'Custom Format',
'change': 'removed',
'value': {
'name': name,
'score': local_dict[name]
}
})
# Compare scores for existing formats
for name in (local_names & remote_names):
if local_dict[name] != remote_dict[name]:
changes.append({
'key': f'Custom Format: {name}: Score',
'change': 'modified',
'from': local_dict[name],
'to': remote_dict[name]
})
return changes
def compare_tags(local_tags, remote_tags):
"""Compare tag lists"""
local_set = set(local_tags or [])
remote_set = set(remote_tags or [])
changes = []
if added := (remote_set - local_set):
changes.append({
'key': 'Tags',
'change': 'added',
'value': sorted(list(added))
})
if removed := (local_set - remote_set):
changes.append({
'key': 'Tags',
'change': 'removed',
'value': sorted(list(removed))
})
return changes
def compare_upgrade_until(local_upgrade, remote_upgrade):
"""Compare upgrade_until objects"""
if not local_upgrade and not remote_upgrade:
return []
changes = []
# Compare name
if local_upgrade.get('name') != remote_upgrade.get('name'):
changes.append({
'key': 'Upgrade Until: Name',
'change': 'modified',
'from': local_upgrade.get('name'),
'to': remote_upgrade.get('name')
})
# Compare description
if local_upgrade.get('description') != remote_upgrade.get('description'):
changes.append({
'key': 'Upgrade Until: Description',
'change': 'modified',
'from': local_upgrade.get('description'),
'to': remote_upgrade.get('description')
})
return changes
def process_generic(old_data, new_data):
if old_data is None and new_data is not None:
return [{'key': 'File', 'change': 'added'}]
if old_data is not None and new_data is None:
return [{'key': 'File', 'change': 'deleted'}]
changes = []
all_keys = set(old_data.keys()).union(set(new_data.keys()))
for key in all_keys:
old_value = old_data.get(key)
new_value = new_data.get(key)
if old_value != new_value:
if key == 'tests':
old_tests = {t['id']: t for t in old_value or []}
new_tests = {t['id']: t for t in new_value or []}
# Handle deleted tests
for test_id in set(old_tests) - set(new_tests):
test = old_tests[test_id]
changes.append({
'key': 'Test',
'change': 'deleted',
'from':
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})',
'to': None
})
# Handle added tests
for test_id in set(new_tests) - set(old_tests):
test = new_tests[test_id]
changes.append({
'key':
'Test',
'change':
'added',
'from':
None,
'to':
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})'
})
# Handle modified tests
for test_id in set(old_tests) & set(new_tests):
if old_tests[test_id] != new_tests[test_id]:
old_test = old_tests[test_id]
new_test = new_tests[test_id]
if old_test['input'] != new_test['input']:
changes.append({
'key':
f'Test #{test_id}',
'change':
'modified',
'from':
f'Input: "{old_test["input"]}"',
'to':
f'Input: "{new_test["input"]}"'
})
if old_test['expected'] != new_test['expected']:
changes.append({
'key':
f'Test #{test_id}',
'change':
'modified',
'from':
f'Expected: {old_test["expected"]}',
'to':
f'Expected: {new_test["expected"]}'
})
else:
changes.append({
'key': key.title(),
'change': 'modified',
'from': old_value,
'to': new_value
})
return changes