diff --git a/backend/app/data/utils.py b/backend/app/data/utils.py
index dfa0bec..b468536 100644
--- a/backend/app/data/utils.py
+++ b/backend/app/data/utils.py
@@ -158,26 +158,39 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
# Check if this is a rename operation
if 'rename' in data:
new_name = data['rename']
+ old_name = filename_to_display(os.path.basename(file_path)[:-4])
+
directory = os.path.dirname(file_path)
new_file_path = os.path.join(directory,
display_to_filename(new_name))
- # Remove rename field before saving
+ # Update references before performing the rename
+ try:
+ updated_files = update_references(category, old_name, new_name)
+ logger.info(f"Updated references in: {updated_files}")
+ except Exception as e:
+ logger.error(f"Failed to update references: {e}")
+ raise Exception(f"Failed to update references: {str(e)}")
+
+ # Remove rename field and update the name field in the data
data_to_save = {k: v for k, v in data.items() if k != 'rename'}
+ data_to_save['name'] = new_name
- # First save the updated content to the CURRENT file location
- save_yaml_file(file_path,
- data_to_save,
- category,
- use_data_name=False)
-
- # Check if file is being tracked by git
repo = git.Repo(REPO_PATH)
rel_old_path = os.path.relpath(file_path, REPO_PATH)
rel_new_path = os.path.relpath(new_file_path, REPO_PATH)
try:
- # Check if file is tracked by git
+ # First, save the content changes to the current file
+ save_yaml_file(file_path,
+ data_to_save,
+ category,
+ use_data_name=False)
+
+ # Stage the content changes first
+ repo.index.add([rel_old_path])
+
+ # Then perform the rename
tracked_files = repo.git.ls_files().splitlines()
is_tracked = rel_old_path in tracked_files
@@ -187,6 +200,8 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
else:
# For untracked files, manually move
os.rename(file_path, new_file_path)
+ # Stage the new file
+ repo.index.add([rel_new_path])
except git.GitCommandError as e:
logger.error(f"Git operation failed: {e}")
@@ -299,6 +314,95 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]:
return False, f"Error checking references: {str(e)}"
+def update_references(category: str, old_name: str,
+ new_name: str) -> List[str]:
+ """
+ Update references to a renamed item across all relevant files.
+ Returns a list of files that were updated.
+ """
+ updated_files = []
+
+ try:
+ # Convert names to use parentheses for comparison
+ old_check_name = old_name.replace('[', '(').replace(']', ')')
+ new_check_name = new_name.replace('[', '(').replace(']', ')')
+
+ if category == 'regex_pattern':
+ # Update references in custom formats
+ format_dir = get_category_directory('custom_format')
+ for format_file in os.listdir(format_dir):
+ if not format_file.endswith('.yml'):
+ continue
+
+ format_path = os.path.join(format_dir, format_file)
+ try:
+ format_data = load_yaml_file(format_path)
+ updated = False
+
+ # Check and update each condition in the format
+ for condition in format_data.get('conditions', []):
+ if (condition['type'] in [
+ 'release_title', 'release_group', 'edition'
+ ] and condition.get('pattern') == old_check_name):
+ condition['pattern'] = new_check_name
+ updated = True
+
+ if updated:
+ save_yaml_file(format_path,
+ format_data,
+ 'custom_format',
+ use_data_name=False)
+ updated_files.append(
+ f"custom format: {format_data['name']}")
+
+ except Exception as e:
+ logger.error(
+ f"Error updating format file {format_file}: {e}")
+ continue
+
+ elif category == 'custom_format':
+ # Update references in quality profiles
+ profile_dir = get_category_directory('profile')
+ for profile_file in os.listdir(profile_dir):
+ if not profile_file.endswith('.yml'):
+ continue
+
+ profile_path = os.path.join(profile_dir, profile_file)
+ try:
+ profile_data = load_yaml_file(profile_path)
+ updated = False
+
+ # Update custom_formats array in profile
+ for format_ref in profile_data.get('custom_formats', []):
+ format_name = format_ref.get('name', '')
+ # Convert format name to use parentheses for comparison
+ format_name = format_name.replace('[', '(').replace(
+ ']', ')')
+
+ if format_name == old_check_name:
+ format_ref['name'] = new_name
+ updated = True
+
+ if updated:
+ save_yaml_file(profile_path,
+ profile_data,
+ 'profile',
+ use_data_name=False)
+ updated_files.append(
+ f"quality profile: {profile_data['name']}")
+
+ except Exception as e:
+ logger.error(
+ f"Error updating profile file {profile_file}: {e}")
+ continue
+
+ return updated_files
+
+ except Exception as e:
+ logger.error(f"Error updating references: {e}")
+ raise
+
+
def test_regex_pattern(
pattern: str,
tests: List[Dict[str, Any]]) -> Tuple[bool, str, List[Dict[str, Any]]]:
diff --git a/backend/app/git/status/incoming_changes.py b/backend/app/git/status/incoming_changes.py
index 6d10313..c94ea38 100644
--- a/backend/app/git/status/incoming_changes.py
+++ b/backend/app/git/status/incoming_changes.py
@@ -8,6 +8,16 @@ from .utils import determine_type, parse_commit_message
logger = logging.getLogger(__name__)
+def extract_name(file_path):
+ """Extract name from file path by removing type prefix and extension"""
+ # Remove the file extension
+ name = os.path.splitext(file_path)[0]
+ # Remove the type prefix (everything before the first '/')
+ if '/' in name:
+ name = name.split('/', 1)[1]
+ return name
+
+
def check_merge_conflict(repo, branch, file_path):
"""Check if pulling a file would cause merge conflicts"""
try:
@@ -15,7 +25,7 @@ def check_merge_conflict(repo, branch, file_path):
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
+ has_changes = 'M' in status_code or 'A' in status_code or 'D' in status_code or 'R' in status_code
else:
# Check for unpushed commits
merge_base = repo.git.merge_base('HEAD',
@@ -49,32 +59,122 @@ def get_commit_message(repo, branch, file_path):
raw_message = repo.git.show(f'HEAD...origin/{branch}', '--format=%B',
'-s', '--', file_path).strip()
return parse_commit_message(raw_message)
- except GitCommandError:
+ except GitCommandError as e:
+ logger.error(
+ f"Git command error getting commit message for {file_path}: {str(e)}"
+ )
return {
"body": "",
"footer": "",
"scope": "",
- "subject": "Unable to retrieve commit message",
+ "subject": f"Error retrieving commit message: {str(e)}",
"type": ""
}
+def parse_commit_message(message):
+ """Parse a commit message into its components"""
+ try:
+ # Default structure
+ parsed = {
+ "type": "Unknown Type",
+ "scope": "Unknown Scope",
+ "subject": "",
+ "body": "",
+ "footer": ""
+ }
+
+ if not message:
+ return parsed
+
+ # Split message into lines
+ lines = message.strip().split('\n')
+
+ # Parse first line (header)
+ if lines:
+ header = lines[0]
+
+ # Try to parse conventional commit format: type(scope): subject
+ import re
+ conventional_format = re.match(r'^(\w+)(?:\(([^)]+)\))?: (.+)$',
+ header)
+
+ if conventional_format:
+ groups = conventional_format.groups()
+ parsed.update({
+ "type": groups[0] or "Unknown Type",
+ "scope": groups[1] or "Unknown Scope",
+ "subject": groups[2]
+ })
+ else:
+ parsed["subject"] = header
+
+ # Parse body and footer
+ if len(lines) > 1:
+ # Find the divider between body and footer (if any)
+ footer_start = -1
+ for i, line in enumerate(lines[1:], 1):
+ if re.match(r'^[A-Z_-]+:', line):
+ footer_start = i
+ break
+
+ # Extract body and footer
+ if footer_start != -1:
+ parsed["body"] = '\n'.join(lines[1:footer_start]).strip()
+ parsed["footer"] = '\n'.join(lines[footer_start:]).strip()
+ else:
+ parsed["body"] = '\n'.join(lines[1:]).strip()
+
+ return parsed
+
+ except Exception as e:
+ logger.error(f"Error parsing commit message: {str(e)}")
+ return {
+ "type": "Unknown Type",
+ "scope": "Unknown Scope",
+ "subject": "Error parsing commit message",
+ "body": "",
+ "footer": ""
+ }
+
+
def get_incoming_changes(repo, branch):
"""Get list of changes that would come in from origin"""
try:
- # Get changed files
- diff_index = repo.git.diff(f'HEAD...origin/{branch}',
- '--name-only').split('\n')
- changed_files = list(filter(None, set(diff_index)))
+ # Get status including renames
+ diff_output = repo.git.diff(f'HEAD...origin/{branch}', '--name-status',
+ '-M').split('\n')
+ changed_files = []
+ rename_mapping = {}
+
+ # Process status to identify renames
+ for line in diff_output:
+ if not line:
+ continue
+ parts = line.split('\t')
+ if len(parts) < 2:
+ continue
+
+ status = parts[0]
+ if status.startswith('R'):
+ old_path, new_path = parts[1], parts[2]
+ rename_mapping[new_path] = old_path
+ changed_files.append(new_path)
+ else:
+ changed_files.append(parts[1])
logger.info(f"Processing {len(changed_files)} incoming changes")
incoming_changes = []
for file_path in changed_files:
try:
+ # Handle renamed files
+ old_path = rename_mapping.get(file_path, file_path)
+ is_rename = file_path in rename_mapping
+
# Get local and remote versions
try:
- local_content = repo.git.show(f'HEAD:{file_path}')
+ local_content = repo.git.show(f'HEAD:{old_path}')
local_data = yaml.safe_load(local_content)
except (GitCommandError, yaml.YAMLError):
local_data = None
@@ -87,7 +187,7 @@ def get_incoming_changes(repo, branch):
remote_data = None
# Skip if no actual changes
- if local_data == remote_data:
+ if local_data == remote_data and not is_rename:
continue
# Check for conflicts and get commit info
@@ -109,13 +209,17 @@ def get_incoming_changes(repo, branch):
'id':
remote_data.get('id') if remote_data else None,
'local_name':
- local_data.get('name') if local_data else None,
+ extract_name(old_path)
+ if is_rename else extract_name(file_path),
'incoming_name':
- remote_data.get('name') if remote_data else None,
+ extract_name(file_path),
'staged':
False
})
+ if is_rename:
+ change['status'] = 'Renamed'
+
incoming_changes.append(change)
except Exception as e:
diff --git a/backend/app/git/status/outgoing_changes.py b/backend/app/git/status/outgoing_changes.py
index 0860805..f8c15f1 100644
--- a/backend/app/git/status/outgoing_changes.py
+++ b/backend/app/git/status/outgoing_changes.py
@@ -8,10 +8,19 @@ from .utils import determine_type
logger = logging.getLogger(__name__)
+def extract_name(file_path):
+ """Extract name from file path by removing type prefix and extension"""
+ # Remove the file extension
+ name = os.path.splitext(file_path)[0]
+ # Remove the type prefix (everything before the first '/')
+ if '/' in name:
+ name = name.split('/', 1)[1]
+ return name
+
+
def get_outgoing_changes(repo):
"""Get list of changes in working directory"""
try:
- # Get status of working directory
status = repo.git.status('--porcelain', '-z').split('\0')
logger.info(f"Processing {len(status)} changes from git status")
@@ -37,50 +46,68 @@ def get_outgoing_changes(repo):
i += 1
continue
- is_staged = x != ' ' and x != '?'
+ # Handle renamed files
+ if x == 'R' or y == 'R':
+ if i + 1 < len(status) and status[i + 1]:
+ outgoing_name = extract_name(file_path)
+ prior_name = extract_name(status[i + 1])
+ original_path = status[i + 1] # Path for old content
+ new_path = file_path # Path for new content
+ is_staged = x == 'R'
+ status_value = 'Renamed'
+ i += 2
+ else:
+ i += 1
+ else:
+ name = extract_name(file_path)
+ prior_name = name
+ outgoing_name = name
+ original_path = file_path
+ new_path = file_path
+ is_staged = x != ' ' and x != '?'
+ status_value = None
+ i += 1
try:
# Get old content (from HEAD)
try:
- old_content = repo.git.show(f'HEAD:{file_path}')
+ old_content = repo.git.show(f'HEAD:{original_path}')
old_data = yaml.safe_load(old_content)
except GitCommandError:
old_data = None
except yaml.YAMLError as e:
logger.warning(
- f"Failed to parse old YAML for {file_path}: {str(e)}")
+ f"Failed to parse old YAML for {original_path}: {str(e)}"
+ )
old_data = None
# Get new content (from working directory)
try:
- full_path = os.path.join(repo.working_dir, file_path)
+ full_path = os.path.join(repo.working_dir, new_path)
with open(full_path, 'r') as f:
new_data = yaml.safe_load(f.read())
except (IOError, yaml.YAMLError) as e:
logger.warning(
- f"Failed to read/parse current file {file_path}: {str(e)}"
+ f"Failed to read/parse current file {new_path}: {str(e)}"
)
new_data = None
# Generate change summary
- change = create_change_summary(old_data, new_data, file_path)
- change['type'] = determine_type(file_path)
+ change = create_change_summary(old_data, new_data, new_path)
+ change['type'] = determine_type(new_path)
change['staged'] = is_staged
+ change['prior_name'] = prior_name
+ change['outgoing_name'] = outgoing_name
+
+ if status_value:
+ change['status'] = status_value
+
changes.append(change)
except Exception as e:
logger.error(f"Failed to process {file_path}: {str(e)}",
exc_info=True)
- i += 1
-
- # Handle renamed files
- if x == 'R' or y == 'R':
- if i + 1 < len(status) and status[i + 1]:
- i += 2 # Skip the old filename entry
- else:
- i += 1
-
return changes
except Exception as e:
diff --git a/backend/app/git/status/status.py b/backend/app/git/status/status.py
index f0a6f35..71e32be 100644
--- a/backend/app/git/status/status.py
+++ b/backend/app/git/status/status.py
@@ -10,6 +10,7 @@ import os
import yaml
import threading
from datetime import datetime
+import json
from ...db import get_settings
logger = logging.getLogger(__name__)
@@ -217,37 +218,85 @@ class GitStatusManager:
return self.status.copy()
+def format_git_status(status):
+ """Format git status for logging with truncation and pretty printing.
+
+ Args:
+ status (dict): The git status dictionary to format
+
+ Returns:
+ str: Formatted status string
+ """
+
+ def truncate_list(lst, max_items=3):
+ """Truncate a list and add count of remaining items."""
+ if len(lst) <= max_items:
+ return lst
+ return lst[:max_items] + [f"... and {len(lst) - max_items} more items"]
+
+ def truncate_string(s, max_length=50):
+ """Truncate a string if it's too long."""
+ if not s or len(s) <= max_length:
+ return s
+ return s[:max_length] + "..."
+
+ # Create a copy to modify
+ formatted_status = status.copy()
+
+ # Truncate lists
+ for key in [
+ 'outgoing_changes', 'merge_conflicts', 'incoming_changes',
+ 'unpushed_files'
+ ]:
+ if key in formatted_status and isinstance(formatted_status[key], list):
+ formatted_status[key] = truncate_list(formatted_status[key])
+
+ # Format any nested dictionaries in the lists
+ for key in formatted_status:
+ if isinstance(formatted_status[key], list):
+ formatted_status[key] = [{
+ k: truncate_string(str(v))
+ for k, v in item.items()
+ } if isinstance(item, dict) else item
+ for item in formatted_status[key]]
+
+ # Convert to JSON with nice formatting
+ formatted_json = json.dumps(formatted_status, indent=2, default=str)
+
+ # Add a timestamp header
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ return f"=== Git Status at {timestamp} ===\n{formatted_json}"
+
+
def get_git_status(repo_path):
try:
status_manager = GitStatusManager.get_instance(repo_path)
status_manager.update_local_status()
- return True, status_manager.get_status()
+ success, status = True, status_manager.get_status()
+
+ # Log the formatted status
+ logger.info("\n" + format_git_status(status))
+
+ return success, status
except git.exc.InvalidGitRepositoryError:
logger.info(f"No git repository found at {repo_path}")
- # Return a valid status object indicating no repo
- return True, {
- # Local status - empty/false everything
+ empty_status = {
"branch": "",
"outgoing_changes": [],
"is_merging": False,
"merge_conflicts": [],
"has_conflicts": False,
-
- # Remote status - explicitly show no repo/remote
"remote_branch_exists": False,
"commits_behind": 0,
"commits_ahead": 0,
"incoming_changes": [],
"has_unpushed_commits": False,
"unpushed_files": [],
-
- # Metadata
"last_local_update": None,
"last_remote_update": None,
-
- # New flag to explicitly indicate no repo
"has_repo": False
}
+ return True, empty_status
except Exception as e:
logger.error(f"Error in get_git_status: {str(e)}", exc_info=True)
return False, str(e)
diff --git a/frontend/src/components/profile/ProfileCard.jsx b/frontend/src/components/profile/ProfileCard.jsx
index 81d2175..56c59e4 100644
--- a/frontend/src/components/profile/ProfileCard.jsx
+++ b/frontend/src/components/profile/ProfileCard.jsx
@@ -176,11 +176,7 @@ const ProfileCard = ({
{/* Description - Fixed Height with Scroll */}
{content.description && (
-
+
{unsanitize(content.description)}
diff --git a/frontend/src/components/profile/ProfileGeneralTab.jsx b/frontend/src/components/profile/ProfileGeneralTab.jsx
index 4e360fe..7b58545 100644
--- a/frontend/src/components/profile/ProfileGeneralTab.jsx
+++ b/frontend/src/components/profile/ProfileGeneralTab.jsx
@@ -7,10 +7,8 @@ const ProfileGeneralTab = ({
name,
description,
tags,
- upgradesAllowed,
onNameChange,
onDescriptionChange,
- onUpgradesAllowedChange,
onAddTag,
onRemoveTag,
error
@@ -42,37 +40,13 @@ const ProfileGeneralTab = ({
)}
-
-
-
-
- Name of this profile. Import will use the same
- name
-
-
-
-
-
- Allow automatic upgrades for this profile
-
-
+
+
+
+ Name of this profile. Import will use the same name
+
+ text-xs font-semibold
+ bg-blue-600/20 text-blue-400
+ group'>
{tag}