Grid (Phase 1):
- Smart row actions by sync status (Connect Git / Save version (N) / Manage / Diagnose)
- "What changed" column with compact stacked category badges
- Status filter chips, sticky bulk panel with live progress, inline row errors + retry
- Dedupe repo-status batch requests (grid feeds the Repositories tab)
- DashboardDataGrid: new actionsCell snippet
GitManager modal (Phases 2-4):
- Simple/Full mode toggle (localStorage), Simple = 3-step wizard
(Changes → Verify → Publish) via new GitWizardStepper
- Undo center + undo toast: soft-undo unpublished commit
(backend POST /repositories/{ref}/undo-commit, reset --soft HEAD~1,
409 guards for pushed/detached/empty HEAD)
- Commit draft autosave per dashboard slug
- Keyboard: Ctrl+Enter commit, 1/2/3 step/tab navigation
- Contextual "You are here: step N" help on the /git page
Excellence (Phase 5):
- First-run GuidedTour (4 spotlight steps, restartable from help panel)
- Pre-flight checklist before create/publish release (ConfirmDialog children
+ confirmDisabled; red checks block the action)
- Rollback confirmation with revert-preview diff; guided conflict progress bar
- Preview link to PREPROD before publishing; human-readable version labels
- prefers-reduced-motion guard; page <title>; aria-live wizard announcements
- docs/design/git-ux-glossary.md — canonical action verbs, ru/en normalized
UX fixes (user feedback):
- Compact change chips (vertical stack, 10px) — no table horizontal scroll
- "Insert into version description" button next to AI key-changes summary
- Guided recovery for "binding belongs to another Git server" (CTA to settings)
- Instant rollback button reveal (CSS visibility via :global, no opacity repaint)
QA fixes:
- Glossary compliance: 0 "commit/коммит" in user-facing strings
- Contract coverage for rollback functions in CommitHistory
- Rollback label aligned to glossary ("Откат к версии" / "Revert to version")
Tests: backend 446 git passed + 5 new undo-commit edge cases;
frontend 3204 passed (8 pre-existing failures unrelated: pipeline locale,
ConfirmationCard, PasswordPrompt, assistant_chat, test_tasks);
new GitReleasePanel pre-flight tests 3/3 green; vite build green.
127 lines
5.2 KiB
Python
127 lines
5.2 KiB
Python
# #region Test.Git.UndoLastCommit [C:3] [TYPE Module] [SEMANTICS test,git,undo,soft-reset,commit]
|
|
# @BRIEF Tests for GitServiceBranchMixin.undo_last_commit — BI-safe soft-undo of unpublished commits.
|
|
# @RELATION BINDS_TO -> [Services.Branch.UndoLastCommit]
|
|
# @TEST_EDGE: no_commits -> HTTPException 409 "No commits to undo"
|
|
# @TEST_EDGE: already_pushed -> HTTPException 409 (ahead_count == 0)
|
|
# @TEST_EDGE: unpushed_with_upstream -> reset --soft HEAD~1 executed, returns undone hash/message
|
|
# @TEST_EDGE: no_upstream -> undo allowed (nothing was ever pushed)
|
|
# @TEST_EDGE: detached_head -> HTTPException 409 (active_branch raises TypeError)
|
|
|
|
import contextlib
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from src.services.git._branch import GitServiceBranchMixin
|
|
|
|
|
|
class TestableGitUndo(GitServiceBranchMixin):
|
|
"""Concrete test class providing _locked and get_repo stubs."""
|
|
def __init__(self, mock_repo=None):
|
|
self._mock_repo = mock_repo or MagicMock()
|
|
|
|
async def get_repo(self, dashboard_id):
|
|
return self._mock_repo
|
|
|
|
def _locked(self, dashboard_id):
|
|
@contextlib.contextmanager
|
|
def _lock():
|
|
yield
|
|
return _lock()
|
|
|
|
|
|
def _make_repo(*, has_head=True, tracking=None, ahead=1, branch_name="dev"):
|
|
repo = MagicMock()
|
|
if has_head:
|
|
head_commit = MagicMock()
|
|
head_commit.hexsha = "abc123def456"
|
|
head_commit.message = "feat: test version\n"
|
|
repo.head.commit = head_commit
|
|
else:
|
|
type(repo.head).commit = property(lambda self: (_ for _ in ()).throw(ValueError("no commits")))
|
|
|
|
active_branch = MagicMock()
|
|
active_branch.name = branch_name
|
|
active_branch.tracking_branch.return_value = tracking # None or MagicMock(name='origin/dev')
|
|
if tracking == "error":
|
|
# Detached HEAD: accessing repo.active_branch itself raises TypeError
|
|
type(repo).active_branch = property(lambda self: (_ for _ in ()).throw(TypeError("detached HEAD")))
|
|
else:
|
|
repo.active_branch = active_branch
|
|
|
|
repo.iter_commits.return_value = iter([MagicMock() for _ in range(ahead)])
|
|
return repo
|
|
|
|
|
|
class TestUndoLastCommit:
|
|
# #region Test.Git.UndoNoCommits [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_undo_no_commits_raises_409(self):
|
|
"""Empty repo → 409 'No commits to undo'."""
|
|
repo = _make_repo(has_head=False)
|
|
svc = TestableGitUndo(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.undo_last_commit(1)
|
|
assert exc_info.value.status_code == 409
|
|
assert "No commits" in exc_info.value.detail
|
|
repo.git.reset.assert_not_called()
|
|
# #endregion Test.Git.UndoNoCommits
|
|
|
|
# #region Test.Git.UndoAlreadyPushed [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_undo_already_pushed_raises_409(self):
|
|
"""Last commit already on remote (ahead == 0) → 409, no reset."""
|
|
tracking = MagicMock()
|
|
tracking.name = "origin/dev"
|
|
repo = _make_repo(tracking=tracking, ahead=0)
|
|
svc = TestableGitUndo(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.undo_last_commit(1)
|
|
assert exc_info.value.status_code == 409
|
|
assert "already published" in exc_info.value.detail
|
|
repo.git.reset.assert_not_called()
|
|
# #endregion Test.Git.UndoAlreadyPushed
|
|
|
|
# #region Test.Git.UndoUnpushedSuccess [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_undo_unpushed_commit_succeeds(self):
|
|
"""Unpushed commit (ahead == 1) → soft reset executed, undone metadata returned."""
|
|
tracking = MagicMock()
|
|
tracking.name = "origin/dev"
|
|
repo = _make_repo(tracking=tracking, ahead=1)
|
|
svc = TestableGitUndo(repo)
|
|
result = await svc.undo_last_commit(1)
|
|
repo.git.reset.assert_called_once_with("--soft", "HEAD~1")
|
|
assert result["status"] == "success"
|
|
assert result["undone_hash"] == "abc123def456"
|
|
assert result["undone_message"] == "feat: test version"
|
|
# #endregion Test.Git.UndoUnpushedSuccess
|
|
|
|
# #region Test.Git.UndoNoUpstream [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_undo_no_upstream_allowed(self):
|
|
"""No upstream tracking branch → nothing pushed, undo allowed."""
|
|
repo = _make_repo(tracking=None)
|
|
svc = TestableGitUndo(repo)
|
|
result = await svc.undo_last_commit(1)
|
|
repo.git.reset.assert_called_once_with("--soft", "HEAD~1")
|
|
assert result["status"] == "success"
|
|
# #endregion Test.Git.UndoNoUpstream
|
|
|
|
# #region Test.Git.UndoDetachedHead [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_undo_detached_head_raises_409(self):
|
|
"""Detached HEAD (active_branch raises TypeError) → 409."""
|
|
repo = _make_repo(has_head=True, tracking="error")
|
|
svc = TestableGitUndo(repo)
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await svc.undo_last_commit(1)
|
|
assert exc_info.value.status_code == 409
|
|
repo.git.reset.assert_not_called()
|
|
# #endregion Test.Git.UndoDetachedHead
|