mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-25 20:32:26 +01:00
- add new regex patterns, matched using PCRE2, with case insensitivity - name, description, pattern, tags - add unit tests, attempt to highlight matches
483 lines
17 KiB
Python
483 lines
17 KiB
Python
import os
|
|
import yaml
|
|
import logging
|
|
from git import GitCommandError
|
|
from .utils import determine_type
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Define the possible states
|
|
UNRESOLVED = "UNRESOLVED" # File is still in conflict, hasn't been resolved and not added
|
|
RESOLVED = "RESOLVED" # File is no longer in conflict, been resolved and has been added
|
|
MODIFY_DELETE = "MODIFY_DELETE" # One side modified the file while the other deleted it
|
|
|
|
|
|
def get_merge_conflicts(repo):
|
|
try:
|
|
if not os.path.exists(os.path.join(repo.git_dir, 'MERGE_HEAD')):
|
|
logger.debug("No MERGE_HEAD found - not in merge state")
|
|
return []
|
|
|
|
conflicts = []
|
|
status = repo.git.status('--porcelain', '-z').split('\0')
|
|
logger.debug(f"Raw status output: {[s for s in status if s]}")
|
|
|
|
for item in status:
|
|
if not item or len(item) < 4:
|
|
continue
|
|
|
|
x, y, file_path = item[0], item[1], item[3:]
|
|
logger.debug(
|
|
f"Processing status item - X: {x}, Y: {y}, Path: {file_path}")
|
|
|
|
# Check for any unmerged status including AU (Added/Unmerged)
|
|
if ((x == 'D' and y == 'U') or (x == 'U' and y == 'D')
|
|
or (x == 'A' and y == 'U')):
|
|
logger.debug("Found modify/delete conflict")
|
|
conflict = process_modify_delete_conflict(
|
|
repo, file_path, deleted_in_head=(x == 'D'))
|
|
if conflict:
|
|
logger.debug(f"Adding modify/delete conflict: {conflict}")
|
|
conflicts.append(conflict)
|
|
elif 'U' in (x, y) or (x == 'D' and y == 'D'):
|
|
logger.debug("Found regular conflict")
|
|
conflict = process_conflict_file(repo, file_path)
|
|
if conflict:
|
|
logger.debug(f"Adding regular conflict: {conflict}")
|
|
conflicts.append(conflict)
|
|
|
|
logger.debug(f"Found {len(conflicts)} conflicts: {conflicts}")
|
|
return conflicts
|
|
except Exception as e:
|
|
logger.error(f"Error getting merge conflicts: {str(e)}", exc_info=True)
|
|
return []
|
|
|
|
|
|
def process_modify_delete_conflict(repo, file_path, deleted_in_head):
|
|
try:
|
|
logger.debug(f"Processing modify/delete conflict for {file_path}")
|
|
logger.debug(f"Deleted in HEAD: {deleted_in_head}")
|
|
|
|
# Check the current status of the file
|
|
status_output = repo.git.status('--porcelain', file_path)
|
|
logger.debug(f"Status output for {file_path}: {status_output}")
|
|
|
|
# If the file exists in working directory and is staged, it's resolved
|
|
file_exists = os.path.exists(os.path.join(repo.working_dir, file_path))
|
|
is_staged = status_output and status_output[0] in ['M', 'A']
|
|
|
|
# Determine the correct status
|
|
if file_exists and is_staged:
|
|
status = RESOLVED
|
|
elif not file_exists and status_output.startswith('D '):
|
|
status = RESOLVED
|
|
else:
|
|
status = MODIFY_DELETE
|
|
|
|
logger.debug(f"Determined status: {status} for {file_path}")
|
|
|
|
# Get file data based on current state
|
|
existing_data = None
|
|
if file_exists:
|
|
try:
|
|
with open(os.path.join(repo.working_dir, file_path), 'r') as f:
|
|
existing_data = yaml.safe_load(f.read())
|
|
except Exception as e:
|
|
logger.warning(f"Failed to read working dir file: {e}")
|
|
|
|
if not existing_data:
|
|
# Try to get MERGE_HEAD version as fallback
|
|
existing_data = get_version_data(repo, 'MERGE_HEAD', file_path)
|
|
|
|
if not existing_data:
|
|
logger.warning(f"Could not get existing version for {file_path}")
|
|
return None
|
|
|
|
file_type = determine_type(file_path)
|
|
logger.debug(f"File type: {file_type}")
|
|
|
|
conflict_details = {
|
|
'conflicting_parameters': [{
|
|
'parameter':
|
|
'File',
|
|
'local_value':
|
|
'deleted' if deleted_in_head else existing_data,
|
|
'incoming_value':
|
|
existing_data if deleted_in_head else 'deleted'
|
|
}]
|
|
}
|
|
|
|
result = {
|
|
'file_path': file_path,
|
|
'type': file_type,
|
|
'name': existing_data.get('name', os.path.basename(file_path)),
|
|
'status': status,
|
|
'conflict_details': conflict_details,
|
|
'deleted_in_head': deleted_in_head
|
|
}
|
|
|
|
logger.debug(f"Processed modify/delete conflict result: {result}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing modify/delete conflict: {str(e)}",
|
|
exc_info=True)
|
|
return None
|
|
|
|
|
|
def process_conflict_file(repo, file_path):
|
|
"""Process a regular conflict file and return its conflict information."""
|
|
try:
|
|
logger.debug(f"Processing conflict file: {file_path}")
|
|
|
|
# Get current and incoming versions
|
|
ours_data = get_version_data(repo, 'HEAD', file_path)
|
|
theirs_data = get_version_data(repo, 'MERGE_HEAD', file_path)
|
|
|
|
if not ours_data or not theirs_data:
|
|
logger.warning(
|
|
f"Missing data for {file_path} - Ours: {bool(ours_data)}, Theirs: {bool(theirs_data)}"
|
|
)
|
|
return None
|
|
|
|
conflict_details = {'conflicting_parameters': []}
|
|
|
|
# Process based on file type
|
|
if file_path.startswith('profiles/'):
|
|
detailed_conflicts = compare_quality_profile(
|
|
ours_data, theirs_data)
|
|
conflict_details['conflicting_parameters'].extend(
|
|
detailed_conflicts)
|
|
else:
|
|
detailed_conflicts = compare_generic(ours_data, theirs_data)
|
|
conflict_details['conflicting_parameters'].extend(
|
|
detailed_conflicts)
|
|
|
|
# Check if file still has unmerged status
|
|
status_output = repo.git.status('--porcelain', file_path)
|
|
logger.debug(f"Status output for {file_path}: {status_output}")
|
|
status = UNRESOLVED if status_output.startswith('UU') else RESOLVED
|
|
|
|
result = {
|
|
'file_path': file_path,
|
|
'type': determine_type(file_path),
|
|
'name': ours_data.get('name'),
|
|
'status': status,
|
|
'conflict_details': conflict_details
|
|
}
|
|
|
|
logger.debug(f"Processed conflict result: {result}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing conflict file {file_path}: {str(e)}",
|
|
exc_info=True)
|
|
return None
|
|
|
|
|
|
def compare_quality_profile(ours_data, theirs_data):
|
|
"""Compare quality profile fields for conflicts"""
|
|
conflicts = []
|
|
|
|
# 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():
|
|
ours_value = ours_data.get(field)
|
|
theirs_value = theirs_data.get(field)
|
|
if ours_value != theirs_value:
|
|
conflicts.append({
|
|
'parameter': display_name,
|
|
'local_value': ours_value,
|
|
'incoming_value': theirs_value
|
|
})
|
|
|
|
# Compare qualities
|
|
ours_qualities = ours_data.get('qualities', [])
|
|
theirs_qualities = theirs_data.get('qualities', [])
|
|
if ours_qualities != theirs_qualities:
|
|
conflicts.extend(compare_qualities(ours_qualities, theirs_qualities))
|
|
|
|
# Compare custom formats
|
|
ours_formats = ours_data.get('custom_formats', [])
|
|
theirs_formats = theirs_data.get('custom_formats', [])
|
|
if ours_formats != theirs_formats:
|
|
conflicts.extend(compare_custom_formats(ours_formats, theirs_formats))
|
|
|
|
# Compare tags
|
|
ours_tags = ours_data.get('tags', [])
|
|
theirs_tags = theirs_data.get('tags', [])
|
|
if ours_tags != theirs_tags:
|
|
conflicts.extend(compare_tags(ours_tags, theirs_tags))
|
|
|
|
# Compare upgrade_until
|
|
ours_upgrade = ours_data.get('upgrade_until', {})
|
|
theirs_upgrade = theirs_data.get('upgrade_until', {})
|
|
if ours_upgrade != theirs_upgrade:
|
|
conflicts.extend(compare_upgrade_until(ours_upgrade, theirs_upgrade))
|
|
|
|
return conflicts
|
|
|
|
|
|
def compare_qualities(ours_qualities, theirs_qualities):
|
|
"""Compare quality groups for conflicts"""
|
|
conflicts = []
|
|
|
|
# Create lookup dictionaries
|
|
ours_dict = {quality.get('name'): quality for quality in ours_qualities}
|
|
theirs_dict = {
|
|
quality.get('name'): quality
|
|
for quality in theirs_qualities
|
|
}
|
|
|
|
# Find added/removed qualities
|
|
ours_names = set(ours_dict.keys())
|
|
theirs_names = set(theirs_dict.keys())
|
|
|
|
# Track additions
|
|
for name in (theirs_names - ours_names):
|
|
conflicts.append({
|
|
'parameter': 'Quality Group',
|
|
'local_value': None,
|
|
'incoming_value': name
|
|
})
|
|
|
|
# Track removals
|
|
for name in (ours_names - theirs_names):
|
|
conflicts.append({
|
|
'parameter': 'Quality Group',
|
|
'local_value': name,
|
|
'incoming_value': None
|
|
})
|
|
|
|
# Compare common qualities
|
|
for name in (ours_names & theirs_names):
|
|
ours_quality = ours_dict[name]
|
|
theirs_quality = theirs_dict[name]
|
|
|
|
# Compare description
|
|
if ours_quality.get('description') != theirs_quality.get(
|
|
'description'):
|
|
conflicts.append({
|
|
'parameter':
|
|
f'Quality Group: {name}: Description',
|
|
'local_value':
|
|
ours_quality.get('description'),
|
|
'incoming_value':
|
|
theirs_quality.get('description')
|
|
})
|
|
|
|
# Compare nested qualities
|
|
ours_nested = {
|
|
q.get('name'): q
|
|
for q in ours_quality.get('qualities', [])
|
|
}
|
|
theirs_nested = {
|
|
q.get('name'): q
|
|
for q in theirs_quality.get('qualities', [])
|
|
}
|
|
|
|
nested_ours = set(ours_nested.keys())
|
|
nested_theirs = set(theirs_nested.keys())
|
|
|
|
for nested_name in (nested_theirs - nested_ours):
|
|
conflicts.append({
|
|
'parameter': f'Quality Group: {name}: Quality',
|
|
'local_value': None,
|
|
'incoming_value': nested_name
|
|
})
|
|
|
|
for nested_name in (nested_ours - nested_theirs):
|
|
conflicts.append({
|
|
'parameter': f'Quality Group: {name}: Quality',
|
|
'local_value': nested_name,
|
|
'incoming_value': None
|
|
})
|
|
|
|
return conflicts
|
|
|
|
|
|
def compare_custom_formats(ours_formats, theirs_formats):
|
|
"""Compare custom formats for conflicts"""
|
|
conflicts = []
|
|
|
|
# Create lookup dictionaries
|
|
ours_dict = {fmt.get('name'): fmt.get('score') for fmt in ours_formats}
|
|
theirs_dict = {fmt.get('name'): fmt.get('score') for fmt in theirs_formats}
|
|
|
|
ours_names = set(ours_dict.keys())
|
|
theirs_names = set(theirs_dict.keys())
|
|
|
|
# Track additions
|
|
for name in (theirs_names - ours_names):
|
|
conflicts.append({
|
|
'parameter': 'Custom Format',
|
|
'local_value': None,
|
|
'incoming_value': {
|
|
'name': name,
|
|
'score': theirs_dict[name]
|
|
}
|
|
})
|
|
|
|
# Track removals
|
|
for name in (ours_names - theirs_names):
|
|
conflicts.append({
|
|
'parameter': 'Custom Format',
|
|
'local_value': {
|
|
'name': name,
|
|
'score': ours_dict[name]
|
|
},
|
|
'incoming_value': None
|
|
})
|
|
|
|
# Compare scores for existing formats
|
|
for name in (ours_names & theirs_names):
|
|
if ours_dict[name] != theirs_dict[name]:
|
|
conflicts.append({
|
|
'parameter': f'Custom Format: {name}: Score',
|
|
'local_value': ours_dict[name],
|
|
'incoming_value': theirs_dict[name]
|
|
})
|
|
|
|
return conflicts
|
|
|
|
|
|
def compare_tags(ours_tags, theirs_tags):
|
|
"""Compare tags for conflicts"""
|
|
conflicts = []
|
|
ours_set = set(ours_tags or [])
|
|
theirs_set = set(theirs_tags or [])
|
|
|
|
if added := (theirs_set - ours_set):
|
|
for tag in sorted(added):
|
|
conflicts.append({
|
|
'parameter': f'Tags: {tag}',
|
|
'local_value': False,
|
|
'incoming_value': True
|
|
})
|
|
|
|
if removed := (ours_set - theirs_set):
|
|
for tag in sorted(removed):
|
|
conflicts.append({
|
|
'parameter': f'Tags: {tag}',
|
|
'local_value': True,
|
|
'incoming_value': False
|
|
})
|
|
|
|
return conflicts
|
|
|
|
|
|
def compare_upgrade_until(ours_upgrade, theirs_upgrade):
|
|
"""Compare upgrade_until objects for conflicts"""
|
|
conflicts = []
|
|
|
|
# Compare name
|
|
if ours_upgrade.get('name') != theirs_upgrade.get('name'):
|
|
conflicts.append({
|
|
'parameter': 'Upgrade Until: Name',
|
|
'local_value': ours_upgrade.get('name'),
|
|
'incoming_value': theirs_upgrade.get('name')
|
|
})
|
|
|
|
# Compare description
|
|
if ours_upgrade.get('description') != theirs_upgrade.get('description'):
|
|
conflicts.append({
|
|
'parameter': 'Upgrade Until: Description',
|
|
'local_value': ours_upgrade.get('description'),
|
|
'incoming_value': theirs_upgrade.get('description')
|
|
})
|
|
|
|
return conflicts
|
|
|
|
|
|
def compare_generic(ours_data, theirs_data):
|
|
conflicts = []
|
|
all_keys = set(ours_data.keys()).union(set(theirs_data.keys()))
|
|
|
|
for key in all_keys:
|
|
if key == 'date_modified':
|
|
continue
|
|
|
|
ours_value = ours_data.get(key)
|
|
theirs_value = theirs_data.get(key)
|
|
|
|
if ours_value != theirs_value:
|
|
if key == 'tests':
|
|
ours_tests = {t['id']: t for t in ours_value or []}
|
|
theirs_tests = {t['id']: t for t in theirs_value or []}
|
|
|
|
# Handle deleted tests
|
|
for test_id in set(ours_tests) - set(theirs_tests):
|
|
conflicts.append({
|
|
'parameter': f'Test {test_id}',
|
|
'local_value': {
|
|
'input': ours_tests[test_id]['input'],
|
|
'expected': ours_tests[test_id]['expected']
|
|
},
|
|
'incoming_value': None
|
|
})
|
|
|
|
# Handle added tests
|
|
for test_id in set(theirs_tests) - set(ours_tests):
|
|
conflicts.append({
|
|
'parameter': f'Test {test_id}',
|
|
'local_value': None,
|
|
'incoming_value': {
|
|
'input': theirs_tests[test_id]['input'],
|
|
'expected': theirs_tests[test_id]['expected']
|
|
}
|
|
})
|
|
|
|
# Handle modified tests
|
|
for test_id in set(ours_tests) & set(theirs_tests):
|
|
if ours_tests[test_id] != theirs_tests[test_id]:
|
|
ours_test = ours_tests[test_id]
|
|
theirs_test = theirs_tests[test_id]
|
|
|
|
if ours_test['input'] != theirs_test['input']:
|
|
conflicts.append({
|
|
'parameter':
|
|
f'Test {test_id} Input',
|
|
'local_value':
|
|
ours_test['input'],
|
|
'incoming_value':
|
|
theirs_test['input']
|
|
})
|
|
|
|
if ours_test['expected'] != theirs_test['expected']:
|
|
conflicts.append({
|
|
'parameter':
|
|
f'Test {test_id} Expected',
|
|
'local_value':
|
|
ours_test['expected'],
|
|
'incoming_value':
|
|
theirs_test['expected']
|
|
})
|
|
else:
|
|
conflicts.append({
|
|
'parameter': key.title(),
|
|
'local_value': ours_value,
|
|
'incoming_value': theirs_value
|
|
})
|
|
|
|
return conflicts
|
|
|
|
|
|
def get_version_data(repo, ref, file_path):
|
|
"""Get YAML data from a specific version of a file."""
|
|
try:
|
|
content = repo.git.show(f'{ref}:{file_path}')
|
|
return yaml.safe_load(content) if content else None
|
|
except GitCommandError as e:
|
|
logger.warning(
|
|
f"Failed to get version data for {ref}:{file_path}: {str(e)}")
|
|
return None
|