mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-25 12:22:24 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
149
backend/app/git/status/commit_history.py
Normal file
149
backend/app/git/status/commit_history.py
Normal 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)}"
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
295
frontend/src/components/settings/git/modal/ViewCommits.jsx
Normal file
295
frontend/src/components/settings/git/modal/ViewCommits.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user