src/eric7/Plugins/VcsPlugins/vcsGit/GitWorktreeDialog.py

Wed, 05 Apr 2023 11:58:22 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Wed, 05 Apr 2023 11:58:22 +0200
branch
eric7
changeset 9971
773ad1f1ed22
parent 9653
e67609152c5e
child 10069
435cc5875135
permissions
-rw-r--r--

Performed some 'ethical' changes.

# -*- coding: utf-8 -*-

# Copyright (c) 2022 - 2023 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a dialog to offer the worktree management functionality.
"""

import os

from PyQt6.QtCore import QDateTime, QProcess, QSize, Qt, QTime, QTimer, pyqtSlot
from PyQt6.QtWidgets import (
    QAbstractButton,
    QDialog,
    QDialogButtonBox,
    QHeaderView,
    QInputDialog,
    QLineEdit,
    QMenu,
    QTreeWidgetItem,
    QWidget,
)

from eric7 import Preferences
from eric7.EricGui import EricPixmapCache
from eric7.EricWidgets import EricMessageBox, EricPathPickerDialog

from .GitDialog import GitDialog
from .Ui_GitWorktreeDialog import Ui_GitWorktreeDialog


class GitWorktreeDialog(QWidget, Ui_GitWorktreeDialog):
    """
    Class implementing a dialog to offer the worktree management functionality.
    """

    StatusRole = Qt.ItemDataRole.UserRole

    def __init__(self, vcs, parent=None):
        """
        Constructor

        @param vcs reference to the vcs object
        @type Git
        @param parent reference to the parent widget (defaults to None)
        @type QWidget (optional)
        """
        super().__init__(parent)
        self.setupUi(self)

        self.__nameColumn = 0
        self.__pathColumn = 1
        self.__commitColumn = 2
        self.__branchColumn = 3

        self.worktreeList.header().setSortIndicator(0, Qt.SortOrder.AscendingOrder)

        self.__refreshButton = self.buttonBox.addButton(
            self.tr("Refresh"), QDialogButtonBox.ButtonRole.ActionRole
        )
        self.__refreshButton.setToolTip(self.tr("Press to refresh the status display"))
        self.__refreshButton.setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(True)

        self.__vcs = vcs
        self.__process = QProcess()
        self.__process.finished.connect(self.__procFinished)
        self.__process.readyReadStandardOutput.connect(self.__readStdout)
        self.__process.readyReadStandardError.connect(self.__readStderr)

        self.__initActionsMenu()

    def __initActionsMenu(self):
        """
        Private method to initialize the actions menu.
        """
        self.__actionsMenu = QMenu()
        self.__actionsMenu.setTearOffEnabled(True)
        self.__actionsMenu.setToolTipsVisible(True)
        self.__actionsMenu.aboutToShow.connect(self.__showActionsMenu)

        self.__addAct = self.__actionsMenu.addAction(
            self.tr("Add..."), self.__worktreeAdd
        )
        self.__addAct.setToolTip(self.tr("Add a new linked worktree"))
        self.__actionsMenu.addSeparator()
        self.__lockAct = self.__actionsMenu.addAction(
            self.tr("Lock..."), self.__worktreeLock
        )
        self.__lockAct.setToolTip(self.tr("Lock the selected worktree"))
        self.__unlockAct = self.__actionsMenu.addAction(
            self.tr("Unlock"), self.__worktreeUnlock
        )
        self.__unlockAct.setToolTip(self.tr("Unlock the selected worktree"))
        self.__actionsMenu.addSeparator()
        self.__moveAct = self.__actionsMenu.addAction(
            self.tr("Move..."), self.__worktreeMove
        )
        self.__moveAct.setToolTip(
            self.tr("Move the selected worktree to a new location")
        )
        self.__actionsMenu.addSeparator()
        self.__removeAct = self.__actionsMenu.addAction(
            self.tr("Remove"), self.__worktreeRemove
        )
        self.__removeAct.setToolTip(self.tr("Remove the selected worktree"))
        self.__removeForcedAct = self.__actionsMenu.addAction(
            self.tr("Forced Remove"), self.__worktreeRemoveForced
        )
        self.__removeForcedAct.setToolTip(
            self.tr("Remove the selected worktree forcefully")
        )
        self.__actionsMenu.addSeparator()
        self.__pruneAct = self.__actionsMenu.addAction(
            self.tr("Prune..."), self.__worktreePrune
        )
        self.__pruneAct.setToolTip(self.tr("Prune worktree information"))
        self.__actionsMenu.addSeparator()
        self.__repairAct = self.__actionsMenu.addAction(
            self.tr("Repair"), self.__worktreeRepair
        )
        self.__repairAct.setToolTip(self.tr("Repair worktree administrative files"))
        self.__repairMultipleAct = self.__actionsMenu.addAction(
            self.tr("Repair Multiple"), self.__worktreeRepairMultiple
        )
        self.__repairMultipleAct.setToolTip(
            self.tr("Repair administrative files of multiple worktrees")
        )

        self.actionsButton.setIcon(EricPixmapCache.getIcon("actionsToolButton"))
        self.actionsButton.setMenu(self.__actionsMenu)

    def closeEvent(self, e):
        """
        Protected slot implementing a close event handler.

        @param e close event (QCloseEvent)
        """
        if (
            self.__process is not None
            and self.__process.state() != QProcess.ProcessState.NotRunning
        ):
            self.__process.terminate()
            QTimer.singleShot(2000, self.__process.kill)
            self.__process.waitForFinished(3000)

        self.__vcs.getPlugin().setPreferences(
            "WorktreeDialogGeometry", self.saveGeometry()
        )

        e.accept()

    def show(self):
        """
        Public slot to show the dialog.
        """
        super().show()

        geom = self.__vcs.getPlugin().getPreferences("WorktreeDialogGeometry")
        if geom.isEmpty():
            s = QSize(900, 600)
            self.resize(s)
        else:
            self.restoreGeometry(geom)

    def __resort(self):
        """
        Private method to resort the tree.
        """
        self.worktreeList.sortItems(
            self.worktreeList.sortColumn(),
            self.worktreeList.header().sortIndicatorOrder(),
        )

    def __resizeColumns(self):
        """
        Private method to resize the list columns.
        """
        self.worktreeList.header().resizeSections(
            QHeaderView.ResizeMode.ResizeToContents
        )
        self.worktreeList.header().setStretchLastSection(True)

    def __generateItem(self, dataLines):
        """
        Private method to generate a worktree entry with the given data.

        @param dataLines lines extracted from the git worktree list output
            with porcelain format
        @type list of str
        """
        checkoutPath = worktreeName = commit = branch = status = ""
        iconName = tooltip = ""
        for line in dataLines:
            if " " in line:
                option, value = line.split(None, 1)
            else:
                option, value = line, ""

            if option == "worktree":
                checkoutPath = value
                worktreeName = os.path.basename(value)
            elif option == "HEAD":
                commit = value[: self.__commitIdLength]
            elif option == "branch":
                branch = value.rsplit("/", 1)[-1]
            elif option == "bare":
                branch = self.tr("(bare)")
            elif option == "detached":
                branch = self.tr("(detached HEAD)")
            elif option == "prunable":
                iconName = "trash"
                tooltip = value
                status = option
            elif option == "locked":
                iconName = "locked"
                tooltip = value
                status = option

        itm = QTreeWidgetItem(
            self.worktreeList, [worktreeName, checkoutPath, commit, branch]
        )
        if iconName:
            itm.setIcon(0, EricPixmapCache.getIcon(iconName))
            if tooltip:
                itm.setToolTip(0, tooltip)

        if self.worktreeList.topLevelItemCount() == 1:
            # the first item is the main worktree
            status = "main"
            font = itm.font(0)
            font.setBold(True)
            if checkoutPath == self.__projectDir:
                # it is the current project as well
                status = "main+current"
                font.setItalic(True)
            for col in range(self.worktreeList.columnCount()):
                itm.setFont(col, font)
        elif checkoutPath == self.__projectDir:
            # it is the current project
            if not status:
                status = "current"
            elif status == "locked":
                status = "locked+current"
            font = itm.font(0)
            font.setItalic(True)
            for col in range(self.worktreeList.columnCount()):
                itm.setFont(col, font)
        itm.setData(0, GitWorktreeDialog.StatusRole, status)

    def start(self, projectDir):
        """
        Public slot to start the git worktree list command.

        @param projectDir name of the project directory
        @type str
        """
        self.errorGroup.hide()
        self.worktreeList.clear()

        self.__ioEncoding = Preferences.getSystem("IOEncoding")

        args = self.__vcs.initCommand("worktree")
        args += ["list", "--porcelain"]
        if self.expireCheckBox.isChecked():
            args += [
                "--expire",
                self.expireDateTimeEdit.dateTime().toString(Qt.DateFormat.ISODate),
            ]

        self.__projectDir = projectDir

        # find the root of the repo
        self.__repodir = self.__vcs.findRepoRoot(projectDir)
        if not self.__repodir:
            return

        self.__outputLines = []
        self.__commitIdLength = self.__vcs.getPlugin().getPreferences("CommitIdLength")

        self.__process.kill()
        self.__process.setWorkingDirectory(self.__repodir)

        self.__process.start("git", args)
        procStarted = self.__process.waitForStarted(5000)
        if not procStarted:
            EricMessageBox.critical(
                self,
                self.tr("Process Generation Error"),
                self.tr(
                    "The process {0} could not be started. "
                    "Ensure, that it is in the search path."
                ).format("git"),
            )
        else:
            self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(
                False
            )
            self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(
                True
            )
            self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setDefault(
                True
            )

            self.__refreshButton.setEnabled(False)

    def __finish(self):
        """
        Private slot called when the process finished or the user pressed
        the button.
        """
        if (
            self.__process is not None
            and self.__process.state() != QProcess.ProcessState.NotRunning
        ):
            self.__process.terminate()
            QTimer.singleShot(2000, self.__process.kill)
            self.__process.waitForFinished(3000)

        self.__refreshButton.setEnabled(True)

        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setEnabled(True)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).setEnabled(False)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setDefault(True)
        self.buttonBox.button(QDialogButtonBox.StandardButton.Close).setFocus(
            Qt.FocusReason.OtherFocusReason
        )

        self.__resort()
        self.__resizeColumns()

    @pyqtSlot(QAbstractButton)
    def on_buttonBox_clicked(self, button):
        """
        Private slot called by a button of the button box clicked.

        @param button button that was clicked
        @type QAbstractButton
        """
        if button == self.buttonBox.button(QDialogButtonBox.StandardButton.Close):
            self.close()
        elif button == self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel):
            self.__finish()
        elif button == self.__refreshButton:
            self.__refreshButtonClicked()

    @pyqtSlot()
    def __refreshButtonClicked(self):
        """
        Private slot to refresh the worktree display.
        """
        self.start(self.__projectDir)

    def __procFinished(self, exitCode, exitStatus):
        """
        Private slot connected to the finished signal.

        @param exitCode exit code of the process (integer)
        @param exitStatus exit status of the process (QProcess.ExitStatus)
        """
        self.__finish()

    def __readStdout(self):
        """
        Private slot to handle the readyReadStandardOutput signal.

        It reads the output of the process, formats it and inserts it into
        the contents pane.
        """
        if self.__process is not None:
            self.__process.setReadChannel(QProcess.ProcessChannel.StandardOutput)

            while self.__process.canReadLine():
                line = str(
                    self.__process.readLine(), self.__ioEncoding, "replace"
                ).strip()
                if line:
                    self.__outputLines.append(line)
                else:
                    self.__generateItem(self.__outputLines)
                    self.__outputLines = []

    def __readStderr(self):
        """
        Private slot to handle the readyReadStandardError signal.

        It reads the error output of the process and inserts it into the
        error pane.
        """
        if self.__process is not None:
            s = str(self.__process.readAllStandardError(), self.__ioEncoding, "replace")
            self.errorGroup.show()
            self.errors.insertPlainText(s)
            self.errors.ensureCursorVisible()

    @pyqtSlot(bool)
    def on_expireCheckBox_toggled(self, checked):
        """
        Private slot to handle a change of the expire checkbox.

        @param checked state of the checkbox
        @type bool
        """
        if checked:
            now = QDateTime.currentDateTime()
            self.expireDateTimeEdit.setMaximumDateTime(now)
            self.expireDateTimeEdit.setMinimumDate(now.date().addDays(-2 * 365))
            self.expireDateTimeEdit.setMinimumTime(QTime(0, 0, 0))
            self.expireDateTimeEdit.setDateTime(now)
        else:
            self.__refreshButtonClicked()

    @pyqtSlot(QDateTime)
    def on_expireDateTimeEdit_dateTimeChanged(self, dateTime):
        """
        Private slot to handle a change of the expire date and time.

        @param dateTime DESCRIPTION
        @type QDateTime
        """
        self.__refreshButtonClicked()

    ###########################################################################
    ## Menu handling methods
    ###########################################################################

    def __showActionsMenu(self):
        """
        Private slot to prepare the actions button menu before it is shown.
        """
        prunableWorktrees = []
        for row in range(self.worktreeList.topLevelItemCount()):
            itm = self.worktreeList.topLevelItem(row)
            status = itm.data(0, GitWorktreeDialog.StatusRole)
            if status == "prunable":
                prunableWorktrees.append(itm.text(self.__pathColumn))

        selectedItems = self.worktreeList.selectedItems()
        enable = bool(selectedItems)
        status = (
            selectedItems[0].data(0, GitWorktreeDialog.StatusRole)
            if selectedItems
            else ""
        )

        self.__lockAct.setEnabled(
            enable
            and status not in ("locked", "locked+current", "main", "main+current")
        )
        self.__unlockAct.setEnabled(enable and status in ("locked", "locked+current"))
        self.__moveAct.setEnabled(
            enable
            and status
            not in (
                "prunable",
                "locked",
                "main",
                "main+current",
                "current",
                "locked+current",
            )
        )
        self.__removeAct.setEnabled(
            enable
            and status
            not in ("locked", "main", "main+current", "current", "locked+current")
        )
        self.__removeForcedAct.setEnabled(
            enable
            and status not in ("main", "main+current", "current", "locked+current")
        )
        self.__pruneAct.setEnabled(bool(prunableWorktrees))

    @pyqtSlot()
    def __worktreeAdd(self):
        """
        Private slot to add a linked worktree.
        """
        from .GitWorktreeAddDialog import GitWorktreeAddDialog

        # find current worktree and take its parent path as the parent directory
        for row in range(self.worktreeList.topLevelItemCount()):
            itm = self.worktreeList.topLevelItem(row)
            if "current" in itm.data(0, GitWorktreeDialog.StatusRole):
                parentDirectory = os.path.dirname(itm.text(self.__pathColumn))
                break
        else:
            parentDirectory = ""

        dlg = GitWorktreeAddDialog(
            parentDirectory,
            self.__vcs.gitGetTagsList(self.__repodir),
            self.__vcs.gitGetBranchesList(self.__repodir),
        )
        if dlg.exec() == QDialog.DialogCode.Accepted:
            params = dlg.getParameters()
            args = ["worktree", "add"]
            if params["force"]:
                args.append("--force")
            if params["detach"]:
                args.append("--detach")
            if params["lock"]:
                args.append("--lock")
                if params["lock_reason"]:
                    args += ["--reason", params["lock_reason"]]
            if params["branch"]:
                args += ["-B" if params["force_branch"] else "-b", params["branch"]]
            args.append(params["path"])
            if params["commit"]:
                args.append(params["commit"])

        dlg = GitDialog(self.tr("Add Worktree"), self.__vcs)
        started = dlg.startProcess(args, workingDir=self.__repodir)
        if started:
            dlg.exec()

            self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeLock(self):
        """
        Private slot to lock a worktree.
        """
        worktree = self.worktreeList.selectedItems()[0].text(self.__pathColumn)
        if not worktree:
            return

        reason, ok = QInputDialog.getText(
            self,
            self.tr("Lock Worktree"),
            self.tr("Enter a reason for the lock:"),
            QLineEdit.EchoMode.Normal,
        )
        if not ok:
            return

        args = ["worktree", "lock"]
        if reason:
            args += ["--reason", reason]
        args.append(worktree)

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if not ok:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                self.tr("Lock Worktree"),
                self.tr(
                    "<p>Locking the selected worktree failed.</p><p>{0}</p>"
                ).format(err),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeUnlock(self):
        """
        Private slot to unlock a worktree.
        """
        worktree = self.worktreeList.selectedItems()[0].text(self.__pathColumn)
        if not worktree:
            return

        args = ["worktree", "unlock", worktree]

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if not ok:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                self.tr("Unlock Worktree"),
                self.tr(
                    "<p>Unlocking the selected worktree failed.</p><p>{0}</p>"
                ).format(err),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeMove(self):
        """
        Private slot to move a worktree to a new location.
        """
        worktree = self.worktreeList.selectedItems()[0].text(self.__pathColumn)
        if not worktree:
            return

        newPath, ok = EricPathPickerDialog.getStrPath(
            self,
            self.tr("Move Worktree"),
            self.tr("Enter the new path for the worktree:"),
            mode=EricPathPickerDialog.EricPathPickerModes.DIRECTORY_MODE,
            strPath=worktree,
            defaultDirectory=os.path.dirname(worktree),
        )
        if not ok:
            return

        args = ["worktree", "move", worktree, newPath]

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if not ok:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                self.tr("Move Worktree"),
                self.tr("<p>Moving the selected worktree failed.</p><p>{0}</p>").format(
                    err
                ),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeRemove(self, force=False):
        """
        Private slot to remove a linked worktree.

        @param force flag indicating a forceful remove (defaults to False)
        @type bool (optional
        """
        worktree = self.worktreeList.selectedItems()[0].text(self.__pathColumn)
        if not worktree:
            return

        title = (
            self.tr("Remove Worktree")
            if force
            else self.tr("Remove Worktree Forcefully")
        )

        ok = EricMessageBox.yesNo(
            self,
            title,
            self.tr(
                "<p>Do you really want to remove the worktree <b>{0}</b>?</p>"
            ).format(worktree),
        )
        if not ok:
            return

        args = ["worktree", "remove"]
        if force:
            args.append("--force")
            if (
                self.worktreeList.selectedItems()[0].data(
                    0, GitWorktreeDialog.StatusRole
                )
                == "locked"
            ):
                # a second '--force' is needed
                args.append("--force")
        args.append(worktree)

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if not ok:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                title,
                self.tr(
                    "<p>Removing the selected worktree failed.</p><p>{0}</p>"
                ).format(err),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeRemoveForced(self):
        """
        Private slot to remove a linked worktree forcefully.
        """
        self.__worktreeRemove(force=True)

    @pyqtSlot()
    def __worktreePrune(self):
        """
        Private slot to prune worktree information.
        """
        from eric7.UI.DeleteFilesConfirmationDialog import DeleteFilesConfirmationDialog

        prunableWorktrees = []
        for row in range(self.worktreeList.topLevelItemCount()):
            itm = self.worktreeList.topLevelItem(row)
            status = itm.data(0, GitWorktreeDialog.StatusRole)
            if status == "prunable":
                prunableWorktrees.append(itm.text(self.__pathColumn))

        if prunableWorktrees:
            dlg = DeleteFilesConfirmationDialog(
                self,
                self.tr("Prune Worktree Information"),
                self.tr(
                    "Do you really want to prune the information of these worktrees?"
                ),
                prunableWorktrees,
            )
            if dlg.exec() == QDialog.DialogCode.Accepted:
                args = ["worktree", "prune"]
                if self.expireCheckBox.isChecked():
                    args += [
                        "--expire",
                        self.expireDateTimeEdit.dateTime().toString(
                            Qt.DateFormat.ISODate
                        ),
                    ]

                proc = QProcess()
                ok = self.__vcs.startSynchronizedProcess(
                    proc, "git", args, self.__repodir
                )
                if not ok:
                    err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
                    EricMessageBox.critical(
                        self,
                        self.tr("Prune Worktree Information"),
                        self.tr(
                            "<p>Pruning of the worktree information failed.</p>"
                            "<p>{0}</p>"
                        ).format(err),
                    )

                self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeRepair(self, worktreePaths=None):
        """
        Private slot to repair worktree administrative files.

        @param worktreePaths list of worktree paths to be repaired (defaults to None)
        @type list of str (optional)
        """
        args = ["worktree", "repair"]
        if worktreePaths:
            args += worktreePaths

        proc = QProcess()
        ok = self.__vcs.startSynchronizedProcess(proc, "git", args, self.__repodir)
        if ok:
            out = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.information(
                self,
                self.tr("Repair Worktree"),
                self.tr(
                    "<p>Repairing of the worktree administrative files succeeded.</p>"
                    "<p>{0}</p>"
                ).format(out),
            )

        else:
            err = str(proc.readAllStandardError(), self.__ioEncoding, "replace")
            EricMessageBox.critical(
                self,
                self.tr("Repair Worktree"),
                self.tr(
                    "<p>Repairing of the worktree administrative files failed.</p>"
                    "<p>{0}</p>"
                ).format(err),
            )

        self.__refreshButtonClicked()

    @pyqtSlot()
    def __worktreeRepairMultiple(self):
        """
        Private slot to repair worktree administrative files for multiple worktree
        paths.
        """
        from .GitWorktreePathsDialog import GitWorktreePathsDialog

        # find current worktree and take its parent path as the parent directory
        for row in range(self.worktreeList.topLevelItemCount()):
            itm = self.worktreeList.topLevelItem(row)
            if "current" in itm.data(0, GitWorktreeDialog.StatusRole):
                parentDirectory = os.path.dirname(itm.text(self.__pathColumn))
                break
        else:
            parentDirectory = ""

        dlg = GitWorktreePathsDialog(parentDirectory, self)
        if dlg.exec() == QDialog.DialogCode.Accepted:
            paths = dlg.getPathsList()

            if paths:
                self.__worktreeRepair(worktreePaths=paths)

eric ide

mercurial