Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions otterwiki/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ def handle_repository_management(form):
git_action_result = _handle_git_force_push()
elif form.get("git_pull"):
git_action_result = _handle_git_pull()
elif form.get("git_reset_remote"):
git_action_result = _handle_git_reset_remote()

# If it was a git action, return to form with results
if git_action_result:
Expand Down Expand Up @@ -439,6 +441,33 @@ def _handle_git_pull():
return {"action": "pull", "success": success, "output": output}


def _handle_git_reset_remote():
"""Handle git reset to remote action."""
from otterwiki.repomgmt import get_repo_manager
from otterwiki.server import app

if not app.config.get('GIT_REMOTE_PULL_ENABLED'):
return {
"action": "reset to remote",
"success": False,
"output": "Pull functionality is not enabled",
}

remote_url = app.config.get('GIT_REMOTE_PULL_URL')
private_key = app.config.get('GIT_REMOTE_PULL_PRIVATE_KEY')

repo_manager = get_repo_manager()
if not repo_manager:
return {
"action": "reset to remote",
"success": False,
"output": "Repository manager not available",
}

success, output = repo_manager.reset_to_remote(remote_url, private_key)
return {"action": "reset to remote", "success": success, "output": output}


def handle_test_mail_preferences(form):
if not has_permission("ADMIN"):
abort(403)
Expand Down
66 changes: 66 additions & 0 deletions otterwiki/repomgmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,72 @@ def pull_from_remote(self, remote_url, private_key=None):
key_path, original_ssh_command, original_ssh_auth_sock
)

def reset_to_remote(self, remote_url, private_key=None):
"""
Fetch from a remote repository and hard-reset the current branch to it,
discarding all local commits and uncommitted changes.
Returns (success, output) tuple.
"""
if not remote_url:
return False, "No remote URL provided"

with self.git_push_pull_Lock:
key_path, original_ssh_command, original_ssh_auth_sock = (
self._setup_ssh_environment(private_key)
)

try:
from otterwiki.server import app

try:
current_branch = self.storage.repo.active_branch.name
except TypeError:
# if there is no current branch we should probably just stop
return False, "No active branch found"

app.logger.info(
f"[RepositoryManager] Fetching from remote for hard reset: {remote_url}"
)

# fetch the remote branch into FETCH_HEAD without touching the working tree
fetch_result = self.storage.repo.git.fetch(
remote_url, current_branch
)
if fetch_result:
app.logger.info(
f"[RepositoryManager] Fetch result: {fetch_result}"
)

app.logger.warning(
f"[RepositoryManager] Hard-resetting '{current_branch}' to FETCH_HEAD "
f"from {remote_url} (local changes will be discarded)"
)
reset_result = self.storage.repo.git.reset(
'--hard', 'FETCH_HEAD'
)

self.storage.notify_repository_changed_from_external()

return (
True,
reset_result or "Hard reset completed successfully",
)

except Exception as e:
try:
from otterwiki.server import app

app.logger.error(
f"[RepositoryManager] Hard reset from remote failed: {e}"
)
except ImportError:
pass
return False, str(e)
finally:
self._restore_ssh_environment(
key_path, original_ssh_command, original_ssh_auth_sock
)

def push_to_remote_async(self, remote_url, private_key=None):
"""
Asynchronously push to remote repository.
Expand Down
1 change: 1 addition & 0 deletions otterwiki/templates/admin/repository_management.html
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ <h2 class="card-title">Repository Management</h2>
{% endif %}
{% if config.GIT_REMOTE_PULL_ENABLED %}
<input class="btn" name="git_pull" type="submit" value="Pull">
<input class="btn" name="git_reset_remote" type="submit" value="Reset to Remote" onclick="return confirm('Are you sure you want to reset to the remote? This will overwrite your local changes and this action is usually irreversible.')">
{% endif %}
</div>
{% endif %}
Expand Down
37 changes: 37 additions & 0 deletions tests/test_repository_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ def test_button_visibility(self, app_with_user, admin_client, test_data):
assert soup.find('input', {'name': 'git_push'}) is None
assert soup.find('input', {'name': 'git_force_push'}) is None
assert soup.find('input', {'name': 'git_pull'}) is None
assert soup.find('input', {'name': 'git_reset_remote'}) is None

# Enable push functionality
self._setup_features(admin_client, test_data, enable_push=True)
Expand All @@ -437,6 +438,7 @@ def test_button_visibility(self, app_with_user, admin_client, test_data):
assert soup.find('input', {'name': 'git_push'}) is not None
assert soup.find('input', {'name': 'git_force_push'}) is not None
assert soup.find('input', {'name': 'git_pull'}) is None
assert soup.find('input', {'name': 'git_reset_remote'}) is None

# Enable both push and pull
self._setup_features(
Expand All @@ -448,6 +450,7 @@ def test_button_visibility(self, app_with_user, admin_client, test_data):
assert soup.find('input', {'name': 'git_push'}) is not None
assert soup.find('input', {'name': 'git_force_push'}) is not None
assert soup.find('input', {'name': 'git_pull'}) is not None
assert soup.find('input', {'name': 'git_reset_remote'}) is not None

@patch('otterwiki.repomgmt.RepositoryManager.push_to_remote')
def test_push_button_functionality(
Expand Down Expand Up @@ -521,6 +524,30 @@ def test_pull_button_functionality(

mock_pull.assert_called_with(test_data['remote_url'], "")

@patch('otterwiki.repomgmt.RepositoryManager.reset_to_remote')
def test_reset_to_remote_button_functionality(
self, mock_pull, app_with_user, admin_client, test_data
):
"""Test that the git pull button works correctly."""
mock_pull.return_value = (True, "HEAD is now at")

# Enable pull functionality
self._setup_features(admin_client, test_data, enable_pull=True)

# Test pull button
rv = admin_client.post(
ADMIN_REPO_MGMT_URL,
data={"git_reset_remote": "Reset to Remote"},
follow_redirects=True,
)
assert rv.status_code == 200

html = rv.data.decode()
assert "Reset To Remote Results" in html
assert "HEAD is now at" in html

mock_pull.assert_called_with(test_data['remote_url'], "")

def test_error_handling_and_styling(
self, app_with_user, admin_client, test_data
):
Expand Down Expand Up @@ -592,6 +619,16 @@ def test_disabled_feature_error_handling(
assert "Pull Results" in html
assert "Pull functionality is not enabled" in html

# Test reset remote button when disabled
rv = admin_client.post(
ADMIN_REPO_MGMT_URL,
data={"git_reset_remote": "Reset to Remote"},
follow_redirects=True,
)
html = rv.data.decode()
assert "Reset To Remote Results" in html
assert "Pull functionality is not enabled" in html

@patch('otterwiki.repomgmt.get_repo_manager')
def test_unavailable_repo_manager_handling(
self, mock_get_repo_manager, app_with_user, admin_client, test_data
Expand Down