diff --git a/backend/app/git/__init__.py b/backend/app/git/__init__.py index 197991f..e30a3a7 100644 --- a/backend/app/git/__init__.py +++ b/backend/app/git/__init__.py @@ -1,5 +1,6 @@ from flask import Blueprint, request, jsonify from .status.status import get_git_status +from .status.commit_history import get_git_commit_history from .branches.manager import Branch_Manager from .operations.manager import GitOperations from .repo.unlink import unlink_repository @@ -359,3 +360,17 @@ def abort_merge(): else: logger.error(f"Error aborting merge: {message}") return jsonify({'success': False, 'error': message}), 400 + + +@bp.route('/commits', methods=['GET']) +def get_commit_history(): + logger.debug("Received request for commit history") + branch = request.args.get('branch') # Optional branch parameter + success, result = get_git_commit_history(REPO_PATH, branch) + + if success: + logger.debug("Successfully retrieved commit history") + return jsonify({'success': True, 'data': result}), 200 + else: + logger.error(f"Failed to retrieve commit history: {result}") + return jsonify({'success': False, 'error': result}), 400 diff --git a/backend/app/git/operations/commit.py b/backend/app/git/operations/commit.py index acfe6ea..a16461e 100644 --- a/backend/app/git/operations/commit.py +++ b/backend/app/git/operations/commit.py @@ -1,16 +1,125 @@ # git/operations/commit.py import git +import os import logging logger = logging.getLogger(__name__) +def parse_git_status(status_output): + """ + Parse git status --porcelain output into a structured format. + + Returns dict with staged and unstaged changes, identifying status of each file. + """ + changes = {} + for line in status_output: + if not line: + continue + + index_status = line[0] # First character: staged status + worktree_status = line[1] # Second character: unstaged status + file_path = line[3:] + + changes[file_path] = { + 'staged': index_status != ' ', + 'staged_status': index_status, + 'unstaged_status': worktree_status + } + + return changes + + def commit_changes(repo_path, files, message): + """ + Commit changes to git repository, optimizing staging operations. + Only re-stages files if their current staging status is incorrect. + + Args: + repo_path: Path to git repository + files: List of files to commit, or None/empty for all staged changes + message: Commit message + + Returns: + tuple: (success: bool, message: str) + """ try: repo = git.Repo(repo_path) - repo.index.add(files) - repo.index.commit(message) - return True, "Successfully committed changes." + + # If no specific files provided, commit all staged changes + if not files: + commit = repo.index.commit(message) + return True, "Successfully committed all staged changes." + + # Get current status of the repository + status_output = repo.git.status('--porcelain').splitlines() + status = parse_git_status(status_output) + + # Track files that need staging operations + to_add = [] + to_remove = [] + already_staged = [] + + for file_path in files: + if file_path in status: + file_status = status[file_path] + + # File is already properly staged + if file_status['staged']: + if file_status['staged_status'] == 'D': + already_staged.append(('deleted', file_path)) + else: + already_staged.append(('modified', file_path)) + continue + + # File needs to be staged + if file_status['unstaged_status'] == 'D': + to_remove.append(file_path) + else: + to_add.append(file_path) + else: + logger.warning(f"File not found in git status: {file_path}") + + # Perform necessary staging operations + if to_add: + logger.debug(f"Staging modified files: {to_add}") + repo.index.add(to_add) + + if to_remove: + logger.debug(f"Staging deleted files: {to_remove}") + repo.index.remove(to_remove, working_tree=True) + + # Commit the changes + commit = repo.index.commit(message) + + # Build detailed success message + staged_counts = { + 'added/modified': len(to_add), + 'deleted': len(to_remove), + 'already_staged': len(already_staged) + } + + message_parts = [] + if staged_counts['added/modified']: + message_parts.append( + f"{staged_counts['added/modified']} files staged") + if staged_counts['deleted']: + message_parts.append( + f"{staged_counts['deleted']} deletions staged") + if staged_counts['already_staged']: + message_parts.append( + f"{staged_counts['already_staged']} files already staged") + + if message_parts: + details = " and ".join(message_parts) + return True, f"Successfully committed changes ({details})" + else: + return True, "Successfully committed changes (no files needed staging)" + + except git.exc.GitCommandError as e: + logger.error(f"Git command error committing changes: {str(e)}", + exc_info=True) + return False, f"Error committing changes: {str(e)}" except Exception as e: logger.error(f"Error committing changes: {str(e)}", exc_info=True) return False, f"Error committing changes: {str(e)}" diff --git a/backend/app/git/operations/resolve.py b/backend/app/git/operations/resolve.py index ca10c83..30bb185 100644 --- a/backend/app/git/operations/resolve.py +++ b/backend/app/git/operations/resolve.py @@ -25,7 +25,6 @@ def resolve_conflicts( """ Resolve merge conflicts based on provided resolutions. """ - # Get list of conflicting files try: status = repo.git.status('--porcelain', '-z').split('\0') conflicts = [] @@ -33,171 +32,230 @@ def resolve_conflicts( if not item or len(item) < 4: continue x, y, file_path = item[0], item[1], item[3:] - if 'U' in (x, y) or (x == 'D' and y == 'D'): - conflicts.append(file_path) + # Include modify/delete conflicts + if 'U' in (x, y) or (x == 'D' and y == 'D') or ( + x == 'D' and y == 'U') or (x == 'U' and y == 'D'): + conflicts.append((file_path, x, y)) + + # Track which files are modify/delete conflicts + modify_delete_conflicts = { + path: (x == 'D' and y == 'U') or (x == 'U' and y == 'D') + for path, x, y in conflicts + } # Validate resolutions are for actual conflicting files for file_path in resolutions: - if file_path not in conflicts: + if file_path not in {path for path, _, _ in conflicts}: return { 'success': False, 'error': f"File not in conflict: {file_path}" } - except Exception as e: - return { - 'success': False, - 'error': f"Failed to get conflicts: {str(e)}" - } + # Store initial states for rollback + initial_states = {} + for file_path in resolutions: + try: + full_path = os.path.join(repo.working_dir, file_path) + try: + with open(full_path, 'r') as f: + initial_states[file_path] = f.read() + except FileNotFoundError: + initial_states[file_path] = None + except Exception as e: + return { + 'success': False, + 'error': f"Couldn't read file {file_path}: {str(e)}" + } - # Store initial states for rollback - initial_states = {} - for file_path in resolutions: - try: - # Join with repo path - full_path = os.path.join(repo.working_dir, file_path) - with open(full_path, 'r') as f: - initial_states[file_path] = f.read() - except Exception as e: - return { - 'success': False, - 'error': f"Couldn't read file {file_path}: {str(e)}" - } - - try: results = {} for file_path, field_resolutions in resolutions.items(): - # Get all three versions - base_data = get_version_data(repo, 'HEAD^', file_path) - ours_data = get_version_data(repo, 'HEAD', file_path) - theirs_data = get_version_data(repo, 'MERGE_HEAD', file_path) + # Handle modify/delete conflicts differently + if modify_delete_conflicts[file_path]: + logger.debug( + f"Handling modify/delete conflict for {file_path}") - if not base_data or not ours_data or not theirs_data: - raise Exception(f"Couldn't get all versions of {file_path}") + # Get the existing version (either from HEAD or MERGE_HEAD) + head_data = get_version_data(repo, 'HEAD', file_path) + merge_head_data = get_version_data(repo, 'MERGE_HEAD', + file_path) - # Start with a deep copy of ours_data to preserve all fields - resolved_data = deepcopy(ours_data) + # Determine which version exists + is_deleted_in_head = head_data is None + existing_data = merge_head_data if is_deleted_in_head else head_data + logger.debug(f"Existing version data: {existing_data}") - # Track changes - kept_values = {} - discarded_values = {} + choice = field_resolutions.get('file') + if not choice: + raise Exception( + "No resolution provided for modify/delete conflict") - # Handle each resolution field - for field, choice in field_resolutions.items(): - if field.startswith('custom_format_'): - # Extract the custom_format ID - try: - cf_id = int(field.split('_')[-1]) - except ValueError: - raise Exception( - f"Invalid custom_format ID in field: {field}") + full_path = os.path.join(repo.working_dir, file_path) - # Find the custom_format in ours and theirs - ours_cf = next( - (item for item in ours_data.get('custom_formats', []) - if item['id'] == cf_id), None) - theirs_cf = next( - (item - for item in theirs_data.get('custom_formats', []) - if item['id'] == cf_id), None) - - if choice == 'local' and ours_cf: - resolved_cf = ours_cf - kept_values[field] = ours_cf - discarded_values[field] = theirs_cf - elif choice == 'incoming' and theirs_cf: - resolved_cf = theirs_cf - kept_values[field] = theirs_cf - discarded_values[field] = ours_cf + if choice == 'local': + if is_deleted_in_head: + logger.debug(f"Keeping file deleted: {file_path}") + # File should stay deleted + try: + os.remove(full_path) + except FileNotFoundError: + pass # File is already gone + repo.index.remove([file_path]) else: - raise Exception( - f"Invalid choice or missing custom_format ID {cf_id} for field: {field}" - ) + logger.debug(f"Keeping local version: {file_path}") + # Keep our version + with open(full_path, 'w') as f: + yaml.safe_dump(head_data, + f, + default_flow_style=False) + repo.index.add([file_path]) - # Update the resolved_data's custom_formats - resolved_cf_list = resolved_data.get('custom_formats', []) - for idx, item in enumerate(resolved_cf_list): - if item['id'] == cf_id: - resolved_cf_list[idx] = resolved_cf - break + elif choice == 'incoming': + if is_deleted_in_head: + logger.debug( + f"Restoring from incoming version: {file_path}") + # Restore the file from MERGE_HEAD + with open(full_path, 'w') as f: + yaml.safe_dump(merge_head_data, + f, + default_flow_style=False) + repo.index.add([file_path]) else: - # If not found, append it - resolved_cf_list.append(resolved_cf) - resolved_data['custom_formats'] = resolved_cf_list + logger.debug(f"Accepting deletion: {file_path}") + # Accept the deletion + try: + os.remove(full_path) + except FileNotFoundError: + pass # File is already gone + repo.index.remove([file_path]) - elif field.startswith('tag_'): - # Extract the tag name - tag_name = field[len('tag_'):] - current_tags = set(resolved_data.get('tags', [])) + results[file_path] = { + 'resolution': + choice, + 'action': + 'delete' if (choice == 'local' and is_deleted_in_head) or + (choice == 'incoming' and not is_deleted_in_head) else + 'keep' + } - if choice == 'local': - # Assume 'local' means keeping the tag from ours - if tag_name in ours_data.get('tags', []): - current_tags.add(tag_name) - kept_values[field] = 'local' - discarded_values[field] = 'incoming' + else: + # Regular conflict resolution + # Get all three versions + base_data = get_version_data(repo, 'HEAD^', file_path) + ours_data = get_version_data(repo, 'HEAD', file_path) + theirs_data = get_version_data(repo, 'MERGE_HEAD', file_path) + + if not base_data or not ours_data or not theirs_data: + raise Exception( + f"Couldn't get all versions of {file_path}") + + # Start with a deep copy of ours_data to preserve all fields + resolved_data = deepcopy(ours_data) + + # Track changes + kept_values = {} + discarded_values = {} + + # Handle each resolution field + for field, choice in field_resolutions.items(): + if field.startswith('custom_format_'): + try: + cf_id = int(field.split('_')[-1]) + except ValueError: + raise Exception( + f"Invalid custom_format ID in field: {field}") + + ours_cf = next( + (item + for item in ours_data.get('custom_formats', []) + if item['id'] == cf_id), None) + theirs_cf = next( + (item + for item in theirs_data.get('custom_formats', []) + if item['id'] == cf_id), None) + + if choice == 'local' and ours_cf: + resolved_cf = ours_cf + kept_values[field] = ours_cf + discarded_values[field] = theirs_cf + elif choice == 'incoming' and theirs_cf: + resolved_cf = theirs_cf + kept_values[field] = theirs_cf + discarded_values[field] = ours_cf else: - current_tags.discard(tag_name) - kept_values[field] = 'none' - discarded_values[field] = 'incoming' - elif choice == 'incoming': - # Assume 'incoming' means keeping the tag from theirs - if tag_name in theirs_data.get('tags', []): - current_tags.add(tag_name) - kept_values[field] = 'incoming' - discarded_values[field] = 'local' + raise Exception( + f"Invalid choice or missing custom_format ID {cf_id}" + ) + + resolved_cf_list = resolved_data.get( + 'custom_formats', []) + for idx, item in enumerate(resolved_cf_list): + if item['id'] == cf_id: + resolved_cf_list[idx] = resolved_cf + break else: - current_tags.discard(tag_name) - kept_values[field] = 'none' - discarded_values[field] = 'local' + resolved_cf_list.append(resolved_cf) + resolved_data['custom_formats'] = resolved_cf_list + + elif field.startswith('tag_'): + tag_name = field[len('tag_'):] + current_tags = set(resolved_data.get('tags', [])) + + if choice == 'local': + if tag_name in ours_data.get('tags', []): + current_tags.add(tag_name) + kept_values[field] = 'local' + discarded_values[field] = 'incoming' + else: + current_tags.discard(tag_name) + kept_values[field] = 'none' + discarded_values[field] = 'incoming' + elif choice == 'incoming': + if tag_name in theirs_data.get('tags', []): + current_tags.add(tag_name) + kept_values[field] = 'incoming' + discarded_values[field] = 'local' + else: + current_tags.discard(tag_name) + kept_values[field] = 'none' + discarded_values[field] = 'local' + else: + raise Exception( + f"Invalid choice for tag field: {field}") + + resolved_data['tags'] = sorted(current_tags) + else: - raise Exception( - f"Invalid choice for tag field: {field}") + field_key = field + if choice == 'local': + resolved_data[field_key] = ours_data.get(field_key) + kept_values[field_key] = ours_data.get(field_key) + discarded_values[field_key] = theirs_data.get( + field_key) + elif choice == 'incoming': + resolved_data[field_key] = theirs_data.get( + field_key) + kept_values[field_key] = theirs_data.get(field_key) + discarded_values[field_key] = ours_data.get( + field_key) + else: + raise Exception( + f"Invalid choice for field: {field}") - resolved_data['tags'] = sorted(current_tags) + # Write resolved version + full_path = os.path.join(repo.working_dir, file_path) + with open(full_path, 'w') as f: + yaml.safe_dump(resolved_data, f, default_flow_style=False) - else: - # Handle other fields - field_key = field - if choice == 'local': - resolved_data[field_key] = ours_data.get(field_key) - kept_values[field_key] = ours_data.get(field_key) - discarded_values[field_key] = theirs_data.get( - field_key) - elif choice == 'incoming': - resolved_data[field_key] = theirs_data.get(field_key) - kept_values[field_key] = theirs_data.get(field_key) - discarded_values[field_key] = ours_data.get(field_key) - else: - raise Exception(f"Invalid choice for field: {field}") + # Stage the resolved file + repo.index.add([file_path]) - # Write resolved version using full path - full_path = os.path.join(repo.working_dir, file_path) - with open(full_path, 'w') as f: - yaml.safe_dump(resolved_data, f, default_flow_style=False) + results[file_path] = { + 'kept_values': kept_values, + 'discarded_values': discarded_values + } - # Stage the resolved file - repo.index.add([file_path]) - - results[file_path] = { - 'kept_values': kept_values, - 'discarded_values': discarded_values - } - - # Log the base, ours, theirs, and resolved versions - logger.info(f"Successfully resolved {file_path}") - logger.info( - f"Base version:\n{yaml.safe_dump(base_data, default_flow_style=False)}" - ) - logger.info( - f"Ours version:\n{yaml.safe_dump(ours_data, default_flow_style=False)}" - ) - logger.info( - f"Theirs version:\n{yaml.safe_dump(theirs_data, default_flow_style=False)}" - ) - logger.info( - f"Resolved version:\n{yaml.safe_dump(resolved_data, default_flow_style=False)}" - ) + logger.debug( + f"Successfully resolved regular conflict for {file_path}") logger.debug("==== Status after resolve_conflicts ====") status_output = repo.git.status('--porcelain', '-z').split('\0') @@ -209,12 +267,18 @@ def resolve_conflicts( return {'success': True, 'results': results} except Exception as e: - # Rollback on any error using full paths + # Rollback on any error for file_path, initial_state in initial_states.items(): try: full_path = os.path.join(repo.working_dir, file_path) - with open(full_path, 'w') as f: - f.write(initial_state) + if initial_state is None: + try: + os.remove(full_path) + except FileNotFoundError: + pass + else: + with open(full_path, 'w') as f: + f.write(initial_state) except Exception as rollback_error: logger.error( f"Failed to rollback {file_path}: {str(rollback_error)}") diff --git a/backend/app/git/operations/stage.py b/backend/app/git/operations/stage.py index 6b9e1b4..4594524 100644 --- a/backend/app/git/operations/stage.py +++ b/backend/app/git/operations/stage.py @@ -1,24 +1,68 @@ # git/operations/stage.py import git +import os import logging logger = logging.getLogger(__name__) def stage_files(repo_path, files): + """ + Stage files in git repository, properly handling both existing and deleted files. + + Args: + repo_path: Path to git repository + files: List of files to stage, or None/empty list to stage all changes + + Returns: + tuple: (success: bool, message: str) + """ try: repo = git.Repo(repo_path) + # Stage all changes if no specific files provided if not files: repo.git.add(A=True) - message = "All changes have been staged." - else: - repo.index.add(files) - message = "Specified files have been staged." + return True, "All changes have been staged." + # Handle specific files + existing_files = [] + deleted_files = [] + + # Separate existing and deleted files + for file_path in files: + full_path = os.path.join(repo_path, file_path) + if os.path.exists(full_path): + existing_files.append(file_path) + else: + # Check if file is tracked but deleted + try: + repo.git.ls_files(file_path, error_unmatch=True) + deleted_files.append(file_path) + except git.exc.GitCommandError: + logger.warning(f"Untracked file not found: {file_path}") + continue + + # Stage existing files + if existing_files: + repo.index.add(existing_files) + + # Stage deleted files + if deleted_files: + repo.index.remove(deleted_files, working_tree=True) + + message_parts = [] + if existing_files: + message_parts.append( + f"{len(existing_files)} existing files staged") + if deleted_files: + message_parts.append(f"{len(deleted_files)} deleted files staged") + + message = " and ".join( + message_parts) if message_parts else "No files staged" return True, message - except git.GitCommandError as e: + except git.exc.GitCommandError as e: logger.error(f"Git command error staging files: {str(e)}", exc_info=True) return False, f"Error staging files: {str(e)}" diff --git a/backend/app/git/status/commit_history.py b/backend/app/git/status/commit_history.py new file mode 100644 index 0000000..ac7fdbb --- /dev/null +++ b/backend/app/git/status/commit_history.py @@ -0,0 +1,149 @@ +# status/commit_history.py + +import git +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + + +def get_git_commit_history(repo_path, branch=None): + """ + Get the commit history for the repository, optionally for a specific branch. + + Args: + repo_path (str): Path to the git repository + branch (str, optional): Branch name to get history for. Defaults to current branch. + + Returns: + tuple: (success: bool, result: dict/str) + On success, returns (True, { + 'local_commits': [...], + 'ahead_count': int, + 'behind_count': int + }) + On failure, returns (False, error_message) + """ + try: + repo = git.Repo(repo_path) + current_branch = repo.active_branch + branch_to_check = branch if branch else current_branch.name + + # Get the tracking branch + tracking_branch = None + try: + tracking_branch = repo.active_branch.tracking_branch() + except Exception as e: + logger.debug(f"No tracking branch found: {e}") + + # Get local commits + commits = [] + try: + # If we have a tracking branch, get commits since the divergence point + if tracking_branch: + merge_base = repo.merge_base(tracking_branch, + current_branch)[0] + commits = list( + repo.iter_commits( + f"{merge_base.hexsha}..{current_branch.name}")) + else: + # If no tracking branch, get recent commits (last 50) + commits = list( + repo.iter_commits(current_branch.name, max_count=50)) + + # Format commit information + formatted_commits = [] + for commit in commits: + # Check if it's a merge commit + is_merge = len(commit.parents) > 1 + + # Get the remote URL for the commit if possible + remote_url = None + if tracking_branch: + remote_url = repo.remote().url + if remote_url.endswith('.git'): + remote_url = remote_url[:-4] + remote_url += f"/commit/{commit.hexsha}" + + commit_info = { + 'hash': commit.hexsha, + 'message': commit.message.strip(), + 'author': f"{commit.author.name} <{commit.author.email}>", + 'date': commit.committed_datetime.isoformat(), + 'isMerge': is_merge, + 'remoteUrl': remote_url, + 'details': { + 'files_changed': [], + 'insertions': 0, + 'deletions': 0 + } + } + + # Get detailed stats + try: + if len(commit.parents) > 0: + # Get the diff between this commit and its first parent + diff = commit.parents[0].diff(commit) + + # Initialize stats + stats = { + 'files_changed': [], + 'insertions': 0, + 'deletions': 0 + } + + # Get the total diff stats using git diff --numstat + raw_stats = repo.git.diff(commit.parents[0].hexsha, + commit.hexsha, + numstat=True).splitlines() + + for line in raw_stats: + if not line.strip(): + continue + adds, dels, file_path = line.split('\t') + # Handle binary files which show up as '-' in numstat + if adds != '-' and dels != '-': + stats['insertions'] += int(adds) + stats['deletions'] += int(dels) + stats['files_changed'].append(file_path) + + commit_info['details'] = stats + + except Exception as e: + logger.debug(f"Error getting commit details: {e}") + commit_info['details'] = { + 'files_changed': [], + 'insertions': 0, + 'deletions': 0 + } + + formatted_commits.append(commit_info) + + # Get ahead/behind counts + ahead_count = 0 + behind_count = 0 + if tracking_branch: + ahead_count = len( + list( + repo.iter_commits( + f"{tracking_branch.name}..{current_branch.name}"))) + behind_count = len( + list( + repo.iter_commits( + f"{current_branch.name}..{tracking_branch.name}"))) + + return True, { + 'local_commits': formatted_commits, + 'ahead_count': ahead_count, + 'behind_count': behind_count, + 'branch': branch_to_check, + 'has_remote': tracking_branch is not None + } + + except git.GitCommandError as e: + logger.error(f"Git command error while getting commits: {e}") + return False, f"Error getting commits: {str(e)}" + + except Exception as e: + logger.exception("Error getting commit history") + return False, f"Unexpected error getting commit history: {str(e)}" diff --git a/backend/app/git/status/merge_conflicts.py b/backend/app/git/status/merge_conflicts.py index f46ff86..f74c7a8 100644 --- a/backend/app/git/status/merge_conflicts.py +++ b/backend/app/git/status/merge_conflicts.py @@ -9,10 +9,10 @@ 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): - """Get all merge conflicts in the repository.""" try: if not os.path.exists(os.path.join(repo.git_dir, 'MERGE_HEAD')): logger.debug("No MERGE_HEAD found - not in merge state") @@ -20,7 +20,6 @@ def get_merge_conflicts(repo): 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: @@ -31,21 +30,103 @@ def get_merge_conflicts(repo): logger.debug( f"Processing status item - X: {x}, Y: {y}, Path: {file_path}") - if 'U' in (x, y) or (x == 'D' and y == 'D'): + # 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") + 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 single conflict file and return its conflict information.""" + """Process a regular conflict file and return its conflict information.""" try: logger.debug(f"Processing conflict file: {file_path}") @@ -82,7 +163,7 @@ def process_conflict_file(repo, file_path): theirs_value }) - # Check if file still has unmerged (UU) status + # 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 @@ -110,6 +191,6 @@ def get_version_data(repo, ref, file_path): content = repo.git.show(f'{ref}:{file_path}') return yaml.safe_load(content) if content else None except GitCommandError as e: - logger.error( - f"Error getting version data for {ref}:{file_path}: {str(e)}") + logger.warning( + f"Failed to get version data for {ref}:{file_path}: {str(e)}") return None diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index ec2bb70..175e74e 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -479,3 +479,32 @@ export const abortMerge = async () => { throw error; } }; + +export const getCommitHistory = async (branch = null) => { + try { + const url = new URL(`${API_BASE_URL}/git/commits`); + if (branch) { + url.searchParams.append('branch', branch); + } + + const response = await axios.get(url.toString(), { + validateStatus: status => { + return (status >= 200 && status < 300) || status === 400; + } + }); + + return response.data; + } catch (error) { + console.error('Error fetching commit history:', error); + if (error.response?.data) { + return { + success: false, + error: error.response.data.error + }; + } + return { + success: false, + error: 'Failed to fetch commit history' + }; + } +}; diff --git a/frontend/src/components/settings/git/ConflictRow.jsx b/frontend/src/components/settings/git/ConflictRow.jsx index 605a230..d892a9c 100644 --- a/frontend/src/components/settings/git/ConflictRow.jsx +++ b/frontend/src/components/settings/git/ConflictRow.jsx @@ -1,9 +1,11 @@ +// ConflictRow.jsx import React, {useState} from 'react'; import {AlertTriangle, GitMerge, Check, Edit2} from 'lucide-react'; import Tooltip from '../../ui/Tooltip'; import ResolveConflicts from './modal/ResolveConflicts'; const ConflictRow = ({change, isDevMode, fetchGitStatus}) => { + console.log('ConflictRow change:', JSON.stringify(change, null, 2)); const [showChanges, setShowChanges] = useState(false); const handleResolveConflicts = e => { @@ -22,6 +24,15 @@ const ConflictRow = ({change, isDevMode, fetchGitStatus}) => { const isResolved = change.status === 'RESOLVED'; + // Check if this is a modify/delete conflict + const fileConflict = change.conflict_details?.conflicting_parameters?.find( + param => param.parameter === 'file' + ); + const isModifyDelete = !!fileConflict; + + // Determine if button should be disabled + const isButtonDisabled = isModifyDelete && isResolved; + return ( <> @@ -58,12 +69,23 @@ const ConflictRow = ({change, isDevMode, fetchGitStatus}) => { @@ -132,32 +132,41 @@ const RepoContainer = ({
-

+

Current Branch:

{status ? ( - + {status.branch} ) : ( - + Loading branch information... )}
- +
+ + +
)} @@ -172,14 +181,26 @@ const RepoContainer = ({ onSubmit={handleUnlinkRepo} /> {settings && status && ( - setShowBranchModal(false)} - repoUrl={settings.gitRepo} - currentBranch={status.branch} - onBranchChange={fetchGitStatus} - isDevMode={isDevMode} - /> + <> + setShowBranchModal(false)} + repoUrl={settings.gitRepo} + currentBranch={status.branch} + onBranchChange={fetchGitStatus} + isDevMode={isDevMode} + /> + setShowCommitModal(false)} + repoUrl={settings.gitRepo} + currentBranch={status.branch} + localCommits={status.local_commits || []} + remoteCommits={status.remote_commits || []} + outgoingChanges={status.outgoing_changes || []} + incomingChanges={status.incoming_changes || []} + /> + )} ); diff --git a/frontend/src/components/settings/git/modal/ResolveConflicts.jsx b/frontend/src/components/settings/git/modal/ResolveConflicts.jsx index 565ad89..dbe1692 100644 --- a/frontend/src/components/settings/git/modal/ResolveConflicts.jsx +++ b/frontend/src/components/settings/git/modal/ResolveConflicts.jsx @@ -278,9 +278,62 @@ const ResolveConflicts = ({ ); }; + const renderModifyDeleteConflict = () => { + if (change.status !== 'MODIFY_DELETE') return null; + + return renderTable( + 'File Status Conflict', + [ + {label: 'Status', width: 'w-1/4'}, + {label: 'Local Version', width: 'w-1/4'}, + {label: 'Remote Version', width: 'w-1/4'}, + {label: 'Resolution', width: 'w-1/4'} + ], + [change.conflict_details.conflicting_parameters[0]], // There's only one parameter for modify/delete + ({parameter, local_value, incoming_value}) => ( + + File + + {local_value === 'deleted' ? 'Deleted' : 'Present'} + + + {incoming_value === 'deleted' ? 'Deleted' : 'Present'} + + + + + + ) + ); + }; + const areAllConflictsResolved = () => { if (!isMergeConflict) return true; + // For modify/delete conflicts, only need to resolve the file status + if (change.status === 'MODIFY_DELETE') { + return !!conflictResolutions['file']; + } + const requiredResolutions = []; // Basic fields @@ -385,9 +438,17 @@ const ResolveConflicts = ({ title={titleContent} width='5xl'>
- {renderBasicFields()} - {renderCustomFormatConflicts()} - {renderTagConflicts()} + {change.status === 'MODIFY_DELETE' ? ( + // For modify/delete conflicts, only show the file status + renderModifyDeleteConflict() + ) : ( + // For regular conflicts, show all the existing sections + <> + {renderBasicFields()} + {renderCustomFormatConflicts()} + {renderTagConflicts()} + + )} {isMergeConflict && (
diff --git a/frontend/src/components/settings/git/modal/ViewChanges.jsx b/frontend/src/components/settings/git/modal/ViewChanges.jsx index 9cac0c6..ef24fa5 100644 --- a/frontend/src/components/settings/git/modal/ViewChanges.jsx +++ b/frontend/src/components/settings/git/modal/ViewChanges.jsx @@ -93,6 +93,28 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => { ); }; + const formatValue = value => { + if (value === null || value === undefined) return '-'; + if (Array.isArray(value)) { + return value + .map(item => { + if ( + typeof item === 'object' && + item.id !== undefined && + item.score !== undefined + ) { + return `Format ${item.id}: ${item.score}`; + } + return String(item); + }) + .join(', '); + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + }; + const renderChanges = () => { const isNewFile = change.status === 'New'; @@ -157,15 +179,15 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => { {isNewFile ? ( - {to ?? value ?? '-'} + {formatValue(value)} ) : ( <> - {from ?? '-'} + {formatValue(from)} - {to ?? value ?? '-'} + {formatValue(to ?? value)} )} diff --git a/frontend/src/components/settings/git/modal/ViewCommits.jsx b/frontend/src/components/settings/git/modal/ViewCommits.jsx new file mode 100644 index 0000000..e7bc1f6 --- /dev/null +++ b/frontend/src/components/settings/git/modal/ViewCommits.jsx @@ -0,0 +1,295 @@ +import React, {useState, useEffect} from 'react'; +import { + GitBranch, + GitCommit, + ExternalLink, + ArrowUpRight, + ArrowDownRight, + GitMerge, + Loader, + User, + Clock, + Hash +} from 'lucide-react'; +import Modal from '../../../ui/Modal'; +import {getCommitHistory} from '../../../../api/api'; +import Alert from '../../../ui/Alert'; + +const ViewCommits = ({isOpen, onClose, repoUrl, currentBranch}) => { + const [selectedCommit, setSelectedCommit] = useState(null); + const [loading, setLoading] = useState(true); + const [commits, setCommits] = useState([]); + const [aheadCount, setAheadCount] = useState(0); + const [behindCount, setBehindCount] = useState(0); + + useEffect(() => { + if (isOpen) { + fetchCommitHistory(); + } + }, [isOpen, currentBranch]); + + const fetchCommitHistory = async () => { + setLoading(true); + try { + const response = await getCommitHistory(currentBranch); + if (response.success) { + setCommits(response.data.local_commits); + setAheadCount(response.data.ahead_count); + setBehindCount(response.data.behind_count); + } else { + Alert.error(response.error || 'Failed to fetch commit history'); + } + } catch (error) { + Alert.error('Failed to fetch commit history'); + } finally { + setLoading(false); + } + }; + + const formatDate = dateStr => { + const date = new Date(dateStr); + return { + date: date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }), + time: date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }) + }; + }; + + const getCommitStatus = commit => { + if (commit.isMerge) { + return { + icon: , + text: 'Merge' + }; + } + + const isAheadCommit = + aheadCount > 0 && commits.indexOf(commit) < aheadCount; + if (isAheadCommit) { + return { + icon: , + text: 'Outgoing' + }; + } + + return { + icon: , + text: 'Synced' + }; + }; + + const renderContent = () => { + if (loading) { + return ( +
+ +
+ ); + } + + if (commits.length === 0) { + return ( +
+ +

No commits found in this branch

+
+ ); + } + + return ( +
+ {commits.map(commit => { + const status = getCommitStatus(commit); + const formattedDate = formatDate(commit.date); + const isExpanded = selectedCommit === commit.hash; + + return ( +
setSelectedCommit(commit.hash)}> +
+ {/* Top section: Message and Hash */} +
+
+ {status.icon} + + {commit.message} + +
+
+ + + {commit.hash.substring(0, 7)} + +
+
+ + {/* Details section */} + {isExpanded && ( + <> +
+
+
+
+
+ Files Changed ( + { + commit.details + .files_changed + .length + } + ): +
+
    + {commit.details.files_changed.map( + (file, idx) => ( +
  • + {file} +
  • + ) + )} +
+
+
+
+ Changes: +
+ {commit.details.insertions > + 0 && ( +
+ + + { + commit.details + .insertions + }{' '} + lines added +
+ )} + {commit.details.deletions > + 0 && ( +
+ - + { + commit.details + .deletions + }{' '} + lines removed +
+ )} + {commit.details + .insertions === 0 && + commit.details + .deletions === + 0 && ( +
+ No line changes + detected +
+ )} +
+
+
+
+ + )} + + {/* Bottom section: Author and date */} +
+
+ + + {commit.author} + +
+
+ + + {formattedDate.date} at{' '} + {formattedDate.time} + +
+
+
+
+ ); + })} +
+ ); + }; + + return ( + +
+ + + Commit History - {currentBranch} + +
+ {(aheadCount > 0 || behindCount > 0) && ( +
+ {aheadCount > 0 && ( +
+ + + {aheadCount} commit + {aheadCount !== 1 ? 's' : ''} ahead + +
+ )} + {behindCount > 0 && ( +
+ + + {behindCount} commit + {behindCount !== 1 ? 's' : ''} behind + +
+ )} +
+ )} +
+ } + width='screen-xl' + height='lg'> +
+
+ {renderContent()} +
+
+ + ); +}; + +export default ViewCommits;