feature: Commit Log Viewer (#7)

* style: slightly decrease font / button size for repo container

* feat: view commit modal to view local commit details

* fix: allow staging and comitting deleted files

* feat: handle modify-delete edge case
- local side deleted, remote modified
- let user pick between restore, keep deleted
- special handling for editing

* feat: handle empty state for commits modal
This commit is contained in:
Sam Chau
2024-11-19 02:02:48 +10:30
committed by Sam Chau
parent ca84a1c95b
commit 529072dc6c
13 changed files with 1172 additions and 237 deletions

View File

@@ -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

View File

@@ -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)}"

View File

@@ -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)}")

View File

@@ -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)}"

View File

@@ -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)}"

View File

@@ -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

View File

@@ -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'
};
}
};

View File

@@ -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 (
<>
<tr className='border-t border-gray-600'>
@@ -58,12 +69,23 @@ const ConflictRow = ({change, isDevMode, fetchGitStatus}) => {
<td className='px-4 py-2 text-left align-middle'>
<Tooltip
content={
isResolved ? 'Edit resolution' : 'Resolve conflicts'
isButtonDisabled
? 'Abort to try again'
: isResolved
? 'Edit resolution'
: 'Resolve conflicts'
}>
<button
onClick={handleResolveConflicts}
className={`flex items-center justify-center px-2 py-1 rounded hover:bg-gray-700 transition-colors text-xs w-full ${
isResolved
onClick={
isButtonDisabled
? undefined
: handleResolveConflicts
}
disabled={isButtonDisabled}
className={`flex items-center justify-center px-2 py-1 rounded transition-colors text-xs w-full ${
isButtonDisabled
? 'bg-gray-500 text-gray-400 cursor-not-allowed'
: isResolved
? 'bg-green-600 hover:bg-green-700'
: 'bg-gray-600 hover:bg-gray-700'
}`}>
@@ -82,18 +104,19 @@ const ConflictRow = ({change, isDevMode, fetchGitStatus}) => {
</Tooltip>
</td>
</tr>
<ResolveConflicts
key={`${change.file_path}-changes`}
isOpen={showChanges}
onClose={() => setShowChanges(false)}
change={change}
isIncoming={false}
isMergeConflict={true}
fetchGitStatus={fetchGitStatus}
isDevMode={isDevMode}
/>
{!isButtonDisabled && (
<ResolveConflicts
key={`${change.file_path}-changes`}
isOpen={showChanges}
onClose={() => setShowChanges(false)}
change={change}
isIncoming={false}
isMergeConflict={true}
fetchGitStatus={fetchGitStatus}
isDevMode={isDevMode}
/>
)}
</>
);
};
export default ConflictRow;

View File

@@ -1,37 +1,59 @@
// ConflictTable.jsx
import ConflictRow from './ConflictRow';
import {Check} from 'lucide-react';
const EmptyState = () => (
<div className='flex flex-col items-center justify-center p-8 text-gray-300'>
<div className='bg-green-500/10 rounded-full p-3 mb-3'>
<Check className='w-6 h-6 text-green-500' />
</div>
<p className='text-lg font-medium'>No conflicts to resolve</p>
<p className='text-sm text-gray-400'>Everything is up to date!</p>
</div>
);
const ConflictTable = ({conflicts, isDevMode, fetchGitStatus}) => {
const hasUnresolvedConflicts = conflicts.some(
conflict => conflict.status !== 'RESOLVED'
);
return (
<div className='border border-gray-600 rounded-md overflow-hidden mt-3'>
<table className='w-full text-sm'>
<thead className='bg-gray-600'>
<tr>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Status
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Type
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/2'>
Name
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Actions
</th>
</tr>
</thead>
<tbody>
{conflicts.map((conflict, index) => (
<ConflictRow
key={`conflict-${index}`}
change={conflict}
isDevMode={isDevMode}
fetchGitStatus={fetchGitStatus}
/>
))}
</tbody>
</table>
{conflicts.length === 0 ? (
<EmptyState />
) : (
<>
<table className='w-full text-sm'>
<thead className='bg-gray-600'>
<tr>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Status
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Type
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/2'>
Name
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Actions
</th>
</tr>
</thead>
<tbody>
{conflicts.map((conflict, index) => (
<ConflictRow
key={`conflict-${index}`}
change={conflict}
isDevMode={isDevMode}
fetchGitStatus={fetchGitStatus}
/>
))}
</tbody>
</table>
</>
)}
</div>
);
};

View File

@@ -14,6 +14,7 @@ import Alert from '../../ui/Alert';
import LinkRepo from './modal/LinkRepo';
import UnlinkRepo from './modal/UnlinkRepo';
import ViewBranches from './modal/ViewBranches';
import ViewCommits from './modal/ViewCommits';
const RepoContainer = ({
settings,
@@ -26,6 +27,7 @@ const RepoContainer = ({
const [showLinkModal, setShowLinkModal] = useState(false);
const [showUnlinkRepo, setShowUnlinkRepo] = useState(false);
const [showBranchModal, setShowBranchModal] = useState(false);
const [showCommitModal, setShowCommitModal] = useState(false);
const handleLinkRepo = () => {
setLoadingAction('link_repo');
@@ -63,13 +65,11 @@ const RepoContainer = ({
}
};
// Handler to close the LinkRepo modal and reset loadingAction
const closeLinkModal = () => {
setShowLinkModal(false);
setLoadingAction('');
};
// Handler to close the UnlinkRepo modal and reset loadingAction
const closeUnlinkModal = () => {
setShowUnlinkRepo(false);
setLoadingAction('');
@@ -81,8 +81,8 @@ const RepoContainer = ({
<div className='flex flex-col sm:flex-row items-start sm:items-center justify-between space-y-3 sm:space-y-0'>
<div className='flex flex-col sm:flex-row sm:items-center'>
<div className='flex items-center'>
<Github className='mr-2 text-blue-400' size={14} />
<h3 className='text-m font-semibold text-gray-100 mr-2 mb-1 sm:mb-0'>
<Github className='mr-2 text-blue-400' size={13} />
<h3 className='text-sm font-semibold text-gray-100 mr-2 mb-1 sm:mb-0'>
{settings
? 'Connected Repository:'
: 'Repository:'}
@@ -93,11 +93,11 @@ const RepoContainer = ({
href={settings.gitRepo}
target='_blank'
rel='noopener noreferrer'
className='text-blue-400 hover:text-blue-300 transition-colors text-m font-medium truncate max-w-xs sm:max-w-md'>
className='text-blue-400 hover:text-blue-300 transition-colors text-sm font-medium truncate max-w-xs sm:max-w-md'>
{settings.gitRepo}
</a>
) : (
<span className='text-gray-400 text-sm'>
<span className='text-gray-400 text-xs'>
No repository linked
</span>
)}
@@ -112,16 +112,16 @@ const RepoContainer = ({
? () => setShowUnlinkRepo(true)
: handleLinkRepo
}
className={`flex items-center px-4 py-2 ${
className={`flex items-center px-3 py-1.5 ${
settings
? 'bg-red-600 hover:bg-red-700'
: 'bg-blue-600 hover:bg-blue-700'
} text-white rounded-md transition-colors duration-200 ease-in-out text-m font-medium shadow-sm`}
} text-white rounded-md transition-colors duration-200 ease-in-out text-sm font-medium shadow-sm`}
disabled={loadingAction !== ''}>
{settings ? (
<Unlink size={16} className='mr-2' />
<Unlink size={14} className='mr-2' />
) : (
<Link size={16} className='mr-2' />
<Link size={14} className='mr-2' />
)}
{settings ? 'Unlink' : 'Link Repository'}
</button>
@@ -132,32 +132,41 @@ const RepoContainer = ({
<div className='flex items-center'>
<GitBranch
className='mr-2 text-blue-400'
size={14}
size={13}
/>
<h3 className='text-m font-semibold text-gray-100 mr-2'>
<h3 className='text-sm font-semibold text-gray-100 mr-2'>
Current Branch:
</h3>
{status ? (
<span className='text-m font-medium'>
<span className='text-sm font-medium'>
{status.branch}
</span>
) : (
<span className='text-gray-400 text-m flex items-center'>
<span className='text-gray-400 text-sm flex items-center'>
<Loader
className='animate-spin mr-2'
size={14}
size={13}
/>
Loading branch information...
</span>
)}
</div>
<button
onClick={() => setShowBranchModal(true)}
className='flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out text-m'
disabled={!status}>
<Eye size={14} className='mr-2' />
View Branches
</button>
<div className='flex space-x-2'>
<button
onClick={() => setShowBranchModal(true)}
className='flex items-center px-2.5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out text-sm'
disabled={!status}>
<Eye size={13} className='mr-2' />
View Branches
</button>
<button
onClick={() => setShowCommitModal(true)}
className='flex items-center px-2.5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out text-sm'
disabled={!status}>
<GitCommit size={13} className='mr-2' />
View Commits
</button>
</div>
</div>
)}
</div>
@@ -172,14 +181,26 @@ const RepoContainer = ({
onSubmit={handleUnlinkRepo}
/>
{settings && status && (
<ViewBranches
isOpen={showBranchModal}
onClose={() => setShowBranchModal(false)}
repoUrl={settings.gitRepo}
currentBranch={status.branch}
onBranchChange={fetchGitStatus}
isDevMode={isDevMode}
/>
<>
<ViewBranches
isOpen={showBranchModal}
onClose={() => setShowBranchModal(false)}
repoUrl={settings.gitRepo}
currentBranch={status.branch}
onBranchChange={fetchGitStatus}
isDevMode={isDevMode}
/>
<ViewCommits
isOpen={showCommitModal}
onClose={() => setShowCommitModal(false)}
repoUrl={settings.gitRepo}
currentBranch={status.branch}
localCommits={status.local_commits || []}
remoteCommits={status.remote_commits || []}
outgoingChanges={status.outgoing_changes || []}
incomingChanges={status.incoming_changes || []}
/>
</>
)}
</div>
);

View File

@@ -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}) => (
<tr key={parameter} className='border-t border-gray-600'>
<td className='px-4 py-2.5 text-gray-300'>File</td>
<td className='px-4 py-2.5 text-gray-300'>
{local_value === 'deleted' ? 'Deleted' : 'Present'}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{incoming_value === 'deleted' ? 'Deleted' : 'Present'}
</td>
<td className='px-4 py-2.5'>
<select
value={conflictResolutions['file'] || ''}
onChange={e =>
handleResolutionChange('file', e.target.value)
}
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
<option value='' disabled>
Select
</option>
<option value='local'>
{change.deleted_in_head
? 'Keep Deleted'
: 'Keep File'}
</option>
<option value='incoming'>
{change.deleted_in_head
? 'Restore File'
: 'Delete File'}
</option>
</select>
</td>
</tr>
)
);
};
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'>
<div className='space-y-4'>
{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 && (
<div className='flex justify-end'>

View File

@@ -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}) => {
</td>
{isNewFile ? (
<td className='px-4 py-2.5 text-gray-300'>
{to ?? value ?? '-'}
{formatValue(value)}
</td>
) : (
<>
<td className='px-4 py-2.5 text-gray-300'>
{from ?? '-'}
{formatValue(from)}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{to ?? value ?? '-'}
{formatValue(to ?? value)}
</td>
</>
)}

View File

@@ -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: <GitMerge size={14} className='text-purple-500' />,
text: 'Merge'
};
}
const isAheadCommit =
aheadCount > 0 && commits.indexOf(commit) < aheadCount;
if (isAheadCommit) {
return {
icon: <ArrowUpRight size={14} className='text-green-500' />,
text: 'Outgoing'
};
}
return {
icon: <GitCommit size={14} className='text-gray-400' />,
text: 'Synced'
};
};
const renderContent = () => {
if (loading) {
return (
<div className='flex items-center justify-center h-40'>
<Loader className='w-6 h-6 animate-spin text-blue-500' />
</div>
);
}
if (commits.length === 0) {
return (
<div className='flex flex-col items-center justify-center h-40 text-gray-400'>
<GitCommit size={24} className='mb-2' />
<p className='text-sm'>No commits found in this branch</p>
</div>
);
}
return (
<div className='space-y-2'>
{commits.map(commit => {
const status = getCommitStatus(commit);
const formattedDate = formatDate(commit.date);
const isExpanded = selectedCommit === commit.hash;
return (
<div
key={commit.hash}
className={`p-3 rounded-lg border ${
isExpanded
? 'border-blue-500 dark:bg-gray-700'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
} transition-colors cursor-pointer`}
onClick={() => setSelectedCommit(commit.hash)}>
<div>
{/* Top section: Message and Hash */}
<div
className={`flex justify-between items-center ${
isExpanded ? 'mb-3' : ''
}`}>
<div className='flex items-center space-x-3 flex-1'>
{status.icon}
<span className='text-xs font-medium font-mono truncate'>
{commit.message}
</span>
</div>
<div className='flex items-center space-x-1.5 text-xs text-gray-400 ml-4'>
<Hash
size={12}
className='text-gray-500'
/>
<span className='font-mono'>
{commit.hash.substring(0, 7)}
</span>
</div>
</div>
{/* Details section */}
{isExpanded && (
<>
<div className='w-full border-t border-gray-700 mb-3'></div>
<div className='mb-3 text-sm text-gray-400 py-3'>
<div className='grid grid-cols-2 gap-4 text-xs'>
<div>
<div className='font-semibold mb-1'>
Files Changed (
{
commit.details
.files_changed
.length
}
):
</div>
<ul className='list-disc list-inside'>
{commit.details.files_changed.map(
(file, idx) => (
<li
key={idx}
className='truncate font-mono'>
{file}
</li>
)
)}
</ul>
</div>
<div>
<div className='font-semibold mb-1'>
Changes:
</div>
{commit.details.insertions >
0 && (
<div className='text-green-400'>
+
{
commit.details
.insertions
}{' '}
lines added
</div>
)}
{commit.details.deletions >
0 && (
<div className='text-red-400'>
-
{
commit.details
.deletions
}{' '}
lines removed
</div>
)}
{commit.details
.insertions === 0 &&
commit.details
.deletions ===
0 && (
<div className='text-gray-400'>
No line changes
detected
</div>
)}
</div>
</div>
</div>
<div className='w-full border-t border-gray-700 mb-3'></div>
</>
)}
{/* Bottom section: Author and date */}
<div className='flex justify-between items-center text-xs text-gray-400'>
<div className='flex items-center space-x-1.5'>
<User
size={12}
className='text-gray-500'
/>
<span className='truncate'>
{commit.author}
</span>
</div>
<div className='flex items-center space-x-1.5'>
<Clock
size={12}
className='text-gray-500'
/>
<span>
{formattedDate.date} at{' '}
{formattedDate.time}
</span>
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={
<div className='flex items-center justify-between w-full'>
<div className='flex items-center space-x-2'>
<GitBranch size={16} className='text-blue-400' />
<span className='text-base'>
Commit History - {currentBranch}
</span>
</div>
{(aheadCount > 0 || behindCount > 0) && (
<div className='flex items-center space-x-3 text-sm px-2'>
{aheadCount > 0 && (
<div className='flex items-center text-green-400 bg-green-400/10 px-2 py-1 rounded'>
<ArrowUpRight size={14} className='mr-1' />
<span>
{aheadCount} commit
{aheadCount !== 1 ? 's' : ''} ahead
</span>
</div>
)}
{behindCount > 0 && (
<div className='flex items-center text-blue-400 bg-blue-400/10 px-2 py-1 rounded'>
<ArrowDownRight
size={14}
className='mr-1'
/>
<span>
{behindCount} commit
{behindCount !== 1 ? 's' : ''} behind
</span>
</div>
)}
</div>
)}
</div>
}
width='screen-xl'
height='lg'>
<div className='space-y-4'>
<div className='overflow-y-auto max-h-[60vh]'>
{renderContent()}
</div>
</div>
</Modal>
);
};
export default ViewCommits;