From c2277d72b4ffc26f0c8e117c08a10a31b324bdfb Mon Sep 17 00:00:00 2001 From: Piotr Keplicz <46023138+keplicz@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:57:42 +0200 Subject: [PATCH] feat(tags): allow to forcefully replace an existing tag --- gitfourchette/forms/newtagdialog.py | 10 +++++-- gitfourchette/forms/newtagdialog.ui | 9 +++++- gitfourchette/forms/ui_newtagdialog.py | 8 ++++-- gitfourchette/tasks/committasks.py | 3 +- test/test_tasks_commit.py | 40 ++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/gitfourchette/forms/newtagdialog.py b/gitfourchette/forms/newtagdialog.py index d896abe6..e66d1421 100644 --- a/gitfourchette/forms/newtagdialog.py +++ b/gitfourchette/forms/newtagdialog.py @@ -36,6 +36,8 @@ def __init__( self.ui = Ui_NewTagDialog() self.ui.setupUi(self) + self.reservedNames = reservedNames + okButton = self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok) okButton.setIcon(stockIcon("git-tag")) okCaptions = [_("&Create"), _("&Create && Push")] @@ -43,11 +45,11 @@ def __init__( populateRemoteComboBox(self.ui.remoteComboBox, remotes) - nameTaken = _("This name is already taken by another tag.") validator = ValidatorMultiplexer(self) validator.setGatedWidgets(okButton) - validator.connectInput(self.ui.nameEdit, lambda name: nameValidationMessage(name, reservedNames, nameTaken)) + validator.connectInput(self.ui.nameEdit, self.validateTagName) validator.run(silenceEmptyWarnings=True) + self.ui.forceCheckBox.toggled.connect(validator.run) # Prime enabled state self.ui.pushCheckBox.click() @@ -62,3 +64,7 @@ def __init__( tquo(targetSubtitle)) self.resize(max(512, self.width()), self.height()) + + def validateTagName(self, name): + nameTaken = _("This name is already taken by another tag.") + return nameValidationMessage(name, [] if self.ui.forceCheckBox.isChecked() else self.reservedNames, nameTaken) diff --git a/gitfourchette/forms/newtagdialog.ui b/gitfourchette/forms/newtagdialog.ui index bff9732b..75f1c13e 100644 --- a/gitfourchette/forms/newtagdialog.ui +++ b/gitfourchette/forms/newtagdialog.ui @@ -64,7 +64,7 @@ - + Qt::Orientation::Horizontal @@ -74,6 +74,13 @@ + + + + Replace an existing tag + + + diff --git a/gitfourchette/forms/ui_newtagdialog.py b/gitfourchette/forms/ui_newtagdialog.py index e42519fb..9eb70a0e 100644 --- a/gitfourchette/forms/ui_newtagdialog.py +++ b/gitfourchette/forms/ui_newtagdialog.py @@ -1,6 +1,6 @@ # Form implementation generated from reading ui file 'newtagdialog.ui' # -# Created by: PyQt6 UI code generator 6.10.2 +# Created by: PyQt6 UI code generator 6.11.0 # # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. @@ -47,7 +47,10 @@ def setupUi(self, NewTagDialog): self.buttonBox.setOrientation(Qt.Orientation.Horizontal) self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) self.buttonBox.setObjectName("buttonBox") - self.formLayout.setWidget(2, QFormLayout.ItemRole.SpanningRole, self.buttonBox) + self.formLayout.setWidget(5, QFormLayout.ItemRole.SpanningRole, self.buttonBox) + self.forceCheckBox = QCheckBox(parent=NewTagDialog) + self.forceCheckBox.setObjectName("forceCheckBox") + self.formLayout.setWidget(3, QFormLayout.ItemRole.FieldRole, self.forceCheckBox) self.label.setBuddy(self.nameEdit) self.retranslateUi(NewTagDialog) @@ -60,3 +63,4 @@ def retranslateUi(self, NewTagDialog): self.label.setText(_p("NewTagDialog", "&Name:")) self.nameEdit.setPlaceholderText(_p("NewTagDialog", "Enter tag name")) self.pushCheckBox.setText(_p("NewTagDialog", "&Push to:")) + self.forceCheckBox.setText(_p("NewTagDialog", "Replace an existing tag")) diff --git a/gitfourchette/tasks/committasks.py b/gitfourchette/tasks/committasks.py index 726140c7..ea2af9a8 100644 --- a/gitfourchette/tasks/committasks.py +++ b/gitfourchette/tasks/committasks.py @@ -402,6 +402,7 @@ def flow(self, oid: Oid = NULL_OID, signIt: bool = False): tagName = dlg.ui.nameEdit.text() pushIt = dlg.ui.pushCheckBox.isChecked() pushTo = dlg.ui.remoteComboBox.currentData() + forceCreate = dlg.ui.forceCheckBox.isChecked() dlg.deleteLater() yield from self.flowEnterWorkerThread() @@ -412,7 +413,7 @@ def flow(self, oid: Oid = NULL_OID, signIt: bool = False): if signIt: repo.create_tag(tagName, oid, ObjectType.COMMIT, self.repo.default_signature, "") else: - repo.create_reference(refName, oid) + repo.create_reference(refName, oid, force=forceCreate) self.epilog.status = _("Tag {0} created on commit {1}.", tquo(tagName), tquo(shortHash(oid))) diff --git a/test/test_tasks_commit.py b/test/test_tasks_commit.py index 69abfb3a..12132d13 100644 --- a/test/test_tasks_commit.py +++ b/test/test_tasks_commit.py @@ -8,6 +8,7 @@ import pytest +from gitfourchette import qt from gitfourchette.forms.checkoutcommitdialog import CheckoutCommitDialog from gitfourchette.forms.commitdialog import CommitDialog from gitfourchette.forms.identitydialog import IdentityDialog @@ -756,6 +757,45 @@ def testNewTag(tempDir, mainWindow): assert newTag in rw.repo.listall_tags() +@pytest.mark.parametrize("forceEnabled,tagOidChanged", [[False, False], [True, True]]) +def testForceNewTag(tempDir, mainWindow, forceEnabled, tagOidChanged): + firstCommit = "2c349335b7f797072cf729c4f3bb0914ecb6dec9" # "First a/a2" + secondCommit = "ac7e7e44c1885efb472ad54a78327d66bfc4ecef" # "First a/a1" + newTag = "cool-tag" + + wd = unpackRepo(tempDir) + + # Nuke remotes for coverage of the no-remote code path. + # (See also testPushTagOnCreate) + with RepoContext(wd) as repo: + repo.remotes.delete("origin") + repo.create_reference(RefPrefix.TAGS + newTag, firstCommit) + + rw = mainWindow.openRepo(wd) + assert newTag in rw.repo.listall_tags() + assert rw.repo.commit_id_from_tag_name(newTag) == Oid(hex=firstCommit) + + secondOid = Oid(hex=secondCommit) + + rw.jump(NavLocator.inCommit(secondOid)) + triggerContextMenuAction(rw.graphView.viewport(), "tag this commit") + + dlg: NewTagDialog = findQDialog(rw, "new tag") + okButton = dlg.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok) + + QTest.keyClicks(dlg.ui.nameEdit, newTag) + assert not okButton.isEnabled() # disabled b/c tag name is duplicated + + if forceEnabled: + QTest.mouseClick(dlg.ui.forceCheckBox, Qt.MouseButton.LeftButton) + assert okButton.isEnabled() + dlg.accept() + else: + dlg.reject() + + assert (rw.repo.commit_id_from_tag_name(newTag) == secondOid) is tagOidChanged + + @pytest.mark.parametrize("method", ["sidebarmenu", "sidebarkey"]) def testDeleteTag(tempDir, mainWindow, method): tagToDelete = "annotated_tag"