# -----------------------------------------------------------------------------
# Copyright (C) 2026 Iliyas Jorio.
# This file is part of GitFourchette, distributed under the GNU GPL v3.
# For full terms, see the included LICENSE file.
# -----------------------------------------------------------------------------

import logging
import os
import shutil
from pathlib import Path
from collections.abc import Callable

from gitfourchette import trtables
from gitfourchette import settings
from gitfourchette.codeview.codewindow import CodeWindow
from gitfourchette.exttools.mergedriver import MergeDriver
from gitfourchette.exttools.toolprocess import ToolProcess
from gitfourchette.gitdriver import *
from gitfourchette.localization import *
from gitfourchette.nav import NavLocator
from gitfourchette.porcelain import *
from gitfourchette.qt import *
from gitfourchette.tasks.repotask import AbortTask, RepoTask, TaskEffects
from gitfourchette.toolbox import *
from gitfourchette.trash import Trash

logger = logging.getLogger(__name__)


class _BaseStagingTask(RepoTask):
    def canKill(self, task: RepoTask):
        # Jump/Refresh tasks shouldn't prevent a staging task from starting
        # when the user holds down RETURN/DELETE in a FileListView
        # to stage/unstage a series of files.
        from gitfourchette import tasks
        return isinstance(task, tasks.Jump | tasks.RefreshRepo)

    def denyConflicts(self, deltas: list[GitDelta], purpose: PatchPurpose):
        # Filter on conflicts
        conflictPaths = [d.new.path for d in deltas if d.conflict is not None]

        # Bail if no conflicts
        if not conflictPaths:
            return

        numPatches = len(deltas)
        numConflicts = len(conflictPaths)

        if numPatches == numConflicts:
            intro = _n("You have selected an unresolved merge conflict.",
                       "You have selected {n} unresolved merge conflicts.", numConflicts)
        else:
            intro = _n("There is an unresolved merge conflict among your selection.",
                       "There are {n} unresolved merge conflicts among your selection.", numConflicts)

        if purpose == PatchPurpose.Stage:
            please = _np("please fix (the merge conflicts)", "Please fix it before staging:", "Please fix them before staging:", numConflicts)
        else:
            please = _np("please fix (the merge conflicts)", "Please fix it before discarding:", "Please fix them before discarding:", numConflicts)

        message = paragraphs(intro, please)
        message += toTightUL(conflictPaths)
        raise AbortTask(message)


class StageFiles(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        if not deltas:  # Nothing to stage (may happen if user keeps pressing Enter in file list view)
            QApplication.beep()
            raise AbortTask()

        self.denyConflicts(deltas, PatchPurpose.Stage)

        paths = [d.new.path for d in deltas]

        self.epilog.effects |= TaskEffects.Workdir
        yield from self.flowCallGit("add", "--", *paths)

        yield from self.debriefPostStage(deltas)

        self.epilog.status = _n("File staged.", "{n} files staged.", len(deltas))

    def debriefPostStage(self, deltas: list[GitDelta]):
        debrief = {}

        for delta in deltas:
            # Staging a tree that isn't registered as a submodule
            if delta.new.mode == FileMode.TREE:
                m = _("You’ve added another Git repo inside your current repo. "
                      "It is STRONGLY RECOMMENDED to absorb it as a submodule before committing.")

            # Staging a submodule with uncommitted changes within
            elif delta.new.mode == FileMode.COMMIT and delta.submoduleWorkdirDirty:
                m = _("Uncommitted changes in the submodule can’t be staged from the parent repository.")

            # Staging a submodule deletion, and the submodule is still in .gitmodules
            elif (delta.status == GitStatus.Deleted
                  and delta.old.mode == FileMode.COMMIT
                  and delta.old.path in self.repo.listall_submodules_dict_at_head()):
                m = _("Don’t forget to remove the submodule from {0} to complete its deletion.", tquo(DOT_GITMODULES))

            # Otherwise, no special message
            else:
                continue

            debrief[delta.new.path] = m

        if not debrief:
            return

        # For better perceived responsivity, show message box asynchronously
        # so that RefreshRepo occurs in the background after the task completes
        yield from self.flowEnterUiThread()
        qmb = asyncMessageBox(
            self.parentWidget(),
            'information',
            self.name(),
            _n("An item requires your attention after staging:", "{n} items require your attention after staging:", len(debrief)))
        addULToMessageBox(qmb, [f"{btag(path)}: {issue}" for path, issue in debrief.items()])
        qmb.show()


class DiscardFiles(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        assert all(d.source == GitDeltaSource.Dirty for d in deltas)

        textPara = []
        really = ""
        verb = _("Discard changes")

        if not deltas:  # Nothing to discard (may happen if user keeps pressing Delete in file list view)
            QApplication.beep()
            raise AbortTask()

        self.denyConflicts(deltas, PatchPurpose.Discard)

        # Filter on submodules
        submoPaths = [d.new.path for d in deltas if d.isSubtreeCommitPatch()]
        numSubmos = len(submoPaths)

        if len(deltas) == 1:
            delta = deltas[0]
            bpath = bquo(delta.new.path)
            if delta.status == GitStatus.Untracked:
                really = _("Really delete {0}?", bpath)
                really += " " + _("Git isn’t tracking this file, so you may not be able to recover it from older commits.")
                verb = _("Delete")
            elif delta.new.mode == FileMode.COMMIT:  # modeWorktree
                really = _("Really discard changes in submodule {0}?", bpath)
            else:
                really = _("Really discard changes to {0}?", bpath)
        else:
            numFiles = len(deltas) - numSubmos
            if numSubmos and numFiles:
                really = _("Really discard changes to {nf} files and in {ns} submodules?", nf=numFiles, ns=numSubmos)
            elif numSubmos:
                really = _("Really discard changes in {n} submodules?", n=numSubmos)
            else:
                really = _("Really discard changes to {n} files?", n=numFiles)

        textPara.append(really)
        if numSubmos:
            submoPostamble = _n(
                "Any uncommitted changes in the submodule will be <b>cleared</b> and the submodule’s HEAD will be reset.",
                "Any uncommitted changes in {n} submodules will be <b>cleared</b> and the submodules’ HEAD will be reset.",
                numSubmos)
            textPara.append(submoPostamble)

        textPara.append(_("This cannot be undone!"))

        yield from self.flowConfirm(text=paragraphs(*textPara), verb=verb, buttonIcon="git-discard")

        self.epilog.effects |= TaskEffects.Workdir
        if numSubmos:
            self.epilog.effects |= TaskEffects.Refs  # We don't have TaskEffects.Submodules so .Refs is the next best thing

        # Back up discarded patches
        if Trash.enabled():
            for delta in deltas:
                try:
                    yield from self._backupDelta(delta)
                except Trash.BackupSkipped as ex:
                    logger.warning(f"Backup skipped: {ex}")

        tracked = [d.new.path for d in deltas if d.status != GitStatus.Untracked]
        untrackedFiles = [d.new.path for d in deltas if d.status == GitStatus.Untracked and d.new.mode != FileMode.TREE]
        untrackedTrees = [d.new.path for d in deltas if d.status == GitStatus.Untracked and d.new.mode == FileMode.TREE]

        # Discard untracked trees. They have already been backed up above,
        # but restore_files_from_index isn't capable of removing trees.
        for untrackedTree in untrackedTrees:
            untrackedTreePath = self.repo.in_workdir(untrackedTree)
            assert os.path.isdir(untrackedTreePath)
            shutil.rmtree(untrackedTreePath)

        if untrackedFiles:
            yield from self.flowCallGit("clean", "--force", "--", *untrackedFiles)
        if tracked:
            yield from self.flowCallGit("checkout", "--", *tracked)

        if submoPaths:
            for submo in submoPaths:
                subWd = os.path.join(self.repo.workdir, submo)
                yield from self.flowCallGit("clean", "-d", "--force", workdir=subWd)
            yield from self.flowCallGit("submodule", "update", "--force", "--init", "--recursive", "--checkout", "--", *submoPaths)

        self.epilog.status = _n("File discarded.", "{n} files discarded.", len(deltas))

    def _backupDelta(self, delta: GitDelta):
        # Don't back up deletions
        if delta.status == GitStatus.Deleted:
            return

        trash = Trash.instance()
        path = delta.new.path
        workdir = self.repo.workdir

        if delta.status == GitStatus.Untracked:
            if delta.new.mode == FileMode.TREE:
                # Untracked tree
                trash.backupTree(workdir, path)
            else:
                # Untracked file
                # TODO: Also binary files?
                trash.backupFile(workdir, path)
        else:
            # TODO: Cache patch in GitDelta? So we don't have to regenerate the patch if we've already displayed it
            tokens = GitDriver.buildDiffCommand(delta)
            driver = yield from self.flowCallGit(*tokens, autoFail=False)
            patchText = driver.stdoutScrollback()
            trash.backupPatch(workdir, patchText, path)


class UnstageFiles(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        if not deltas:  # Nothing to unstage (may happen if user keeps pressing Delete in file list view)
            QApplication.beep()
            raise AbortTask()

        paths = []
        for delta in deltas:
            paths.append(delta.new.path)
            if delta.status == GitStatus.Renamed:
                paths.append(delta.old.path)

        self.epilog.effects |= TaskEffects.Workdir
        # Not using 'restore --staged' because it doesn't work in an empty repo
        yield from self.flowCallGit("reset", "--", *paths)

        self.epilog.status = _n("File unstaged.", "{n} files unstaged.", len(deltas))


class DiscardModeChanges(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        paths = [delta.new.path for delta in deltas]
        numFiles = len(paths)

        if numFiles == 0:  # Nothing to unstage (may happen if user keeps pressing Delete in file list view)
            QApplication.beep()
            raise AbortTask()

        text = paragraphs(
            (_("Really discard mode change in {0}?", bquo(paths[0]))
             if numFiles == 1 else
             _("Really discard mode changes in <b>{n} files</b>?", n=numFiles)),
            _("This cannot be undone!"))

        yield from self.flowConfirm(text=text, verb=_("Discard mode changes"), buttonIcon="git-discard")

        yield from self.flowEnterWorkerThread()
        self.epilog.effects |= TaskEffects.Workdir
        self.repo.discard_mode_changes(paths)


class UnstageModeChanges(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        if not deltas:  # Nothing to unstage (may happen if user keeps pressing Delete in file list view)
            QApplication.beep()
            raise AbortTask()

        yield from self.flowEnterWorkerThread()
        self.epilog.effects |= TaskEffects.Workdir

        self.repo.refresh_index()
        index = self.repo.index
        for delta in deltas:
            of = delta.old
            nf = delta.new
            if (of.mode != nf.mode
                    and delta.status not in [GitStatus.Added, GitStatus.Deleted, GitStatus.Untracked]
                    and of.mode in [FileMode.BLOB, FileMode.BLOB_EXECUTABLE]):
                index.add(IndexEntry(nf.path, Oid(hex=nf.id), of.mode))
        index.write()


class ApplyPatch(RepoTask):
    def flow(self, delta: GitDelta, subPatch: str, purpose: PatchPurpose):
        if not subPatch:
            QApplication.beep()
            verb = trtables.enum(purpose & PatchPurpose.VerbMask).lower()
            message = _("Can’t {verb} the selection because no red/green lines are selected.", verb=verb)
            raise AbortTask(message, asStatusMessage=True)

        if purpose & PatchPurpose.Discard:
            title = trtables.enum(purpose)
            isHunk = purpose & PatchPurpose.Hunk
            text = paragraphs(
                _("Really discard this hunk?") if isHunk else _("Really discard the selected lines?"),
                _("This cannot be undone!"))
            yield from self.flowConfirm(title, text=text, verb=title, buttonIcon="git-discard-lines")

            try:
                Trash.instance().backupPatch(self.repo.workdir, subPatch, delta.new.path)
            except Trash.BackupSkipped as ex:
                logger.warning(f"Backup skipped: {ex}")

            applyLocation = ApplyLocation.WORKDIR
        else:
            applyLocation = ApplyLocation.INDEX

        yield from self.flowEnterWorkerThread()
        self.epilog.effects |= TaskEffects.Workdir

        # libgit2's index must be fresh in order to apply a partial patch to the index.
        if applyLocation == ApplyLocation.INDEX:
            self.repo.refresh_index()

        self.repo.apply(subPatch, applyLocation)

        self.epilog.status = ApplyPatch.patchPurposePastTense(purpose)

    @staticmethod
    def patchPurposePastTense(purpose: PatchPurpose):
        pp = PatchPurpose
        t = {
            pp.Lines | pp.Stage: _p("PatchPurpose", "Lines staged."),
            pp.Lines | pp.Unstage: _p("PatchPurpose", "Lines unstaged."),
            pp.Lines | pp.Discard: _p("PatchPurpose", "Lines discarded."),
            pp.Hunk | pp.Stage: _p("PatchPurpose", "Hunk staged."),
            pp.Hunk | pp.Unstage: _p("PatchPurpose", "Hunk unstaged."),
            pp.Hunk | pp.Discard: _p("PatchPurpose", "Hunk discarded."),
            pp.File | pp.Stage: _n("File staged.", "{n} files staged.", 1),
            pp.File | pp.Unstage: _n("File unstaged.", "{n} files unstaged.", 1),
            pp.File | pp.Discard: _n("File discarded.", "{n} files discarded.", 1),
        }
        return t.get(purpose, "")


class HardSolveConflicts(RepoTask):
    def flow(self, conflicts: list[GitConflict], keepOurs: bool):
        # First ask for confirmation
        yield from self._confirm(conflicts, keepOurs)

        # Sort files in 'keep' and 'nuke' buckets
        files = [c.ours if keepOurs else c.theirs for c in conflicts]
        keepPaths = [f.path for f in files if not f.isId0()]
        nukePaths = [f.path for f in files if f.isId0()]

        # Back up nuked files + THEIRS
        if Trash.enabled():
            backupList = nukePaths[:]
            if not keepOurs:
                backupList += keepPaths

            for path in backupList:
                try:
                    Trash.instance().backupFile(self.repo.workdir, path)
                except Trash.BackupSkipped as ex:
                    logger.warning(f"Backup skipped: {ex}")

        self.epilog.effects |= TaskEffects.Workdir

        # Restore desired sides, then stage the files to resolve the conflict
        if keepPaths:
            sideArg = "--ours" if keepOurs else "--theirs"
            yield from self.flowCallGit("restore", "--progress", sideArg, "--", *keepPaths)
            yield from self.flowCallGit("add", "--force", "--", *keepPaths)

        # Stage deletions to resolve the conflict
        if nukePaths:
            yield from self.flowCallGit("rm", "--", *nukePaths)

        # Jump to any staged file after the task
        for path in keepPaths:
            if Path(self.repo.in_workdir(path)).is_file():
                self.epilog.jumpTo = NavLocator.inStaged(path)
                break

        self.epilog.status = _n("Conflict resolved.", "{n} conflicts resolved.", len(conflicts))

    def _confirm(self, conflicts: list[GitConflict], keepOurs: bool):
        title = _n("Resolve conflict", "Resolve {n} conflicts", len(conflicts))
        verb = _("Keep OUR version") if keepOurs else _("Accept THEIR version")
        promptSuffix = ""

        sidesSet = {c.sides for c in conflicts}
        if len(sidesSet) == 1:
            from gitfourchette.forms.conflictview import GitConflictSidesLocalization
            strings = GitConflictSidesLocalization.getStrings(conflicts[0].sides)
            a = _("Reject incoming changes") if keepOurs else _("Accept incoming changes")
            b = strings.ours2 if keepOurs else strings.theirs2
            promptSuffix = f"<p>\u2192 {a}.<br>\u2192 {b}."

        if len(conflicts) == 1:
            if keepOurs:
                singlePath = hquoe(Path(conflicts[0].ours.path).name)
            else:
                singlePath = hquoe(Path(conflicts[0].theirs.path).name)
            prompt = _("<b>{verb}</b> to resolve the conflict on {single}?",
                       verb=verb, single=singlePath)
        else:
            prompt = _n("<b>{verb}</b> to resolve the conflict on {n} file?",
                        "<b>{verb}</b> to resolve conflicts on {n} files?",
                        n=len(conflicts), verb=verb)

        yield from self.flowConfirm(title, text=prompt + promptSuffix, verb=verb)


class OpenMergeTool(RepoTask):
    def flow(self, conflict: GitConflict, reopenWorkInProgress: bool):
        mergeDriver = MergeDriver.findOngoingMerge(conflict)

        if mergeDriver is None:
            mergeDriver = yield from self.makeMergeDriver(conflict)

        mergeDriver.startProcess(reopenWorkInProgress)
        return mergeDriver

    def makeMergeDriver(self, conflict: GitConflict):
        mergeTempDir = QTemporaryDir(str(Path(qTempDir(), "merge")))
        into = mergeTempDir.path()
        repo = self.repo

        def dump(df: GitDeltaFile, prefix: str):
            path = yield from self.flowSubtask(SaveDeltaFileAs, df, saveIntoDir=into, prefix=prefix)
            return path

        targetPath = repo.in_workdir(conflict.ours.path)
        baseName = Path(targetPath).name

        # Dump OURS and THEIRS blobs into the temporary directory
        oursPath = yield from dump(conflict.ours, prefix="[OURS]")
        theirsPath = yield from dump(conflict.theirs, prefix="[THEIRS]")

        if conflict.ancestor:
            # Dump ANCESTOR blob into the temporary directory
            ancestorPath = yield from dump(conflict.ancestor, prefix="[ANCESTOR]")
        else:
            # There's no ancestor! Some merge tools can fake a 3-way merge without
            # an ancestor (e.g. PyCharm), but others won't (e.g. VS Code).
            # To make sure we get a 3-way merge, copy our current workdir file as
            # the fake ANCESTOR file. It should contain chevron conflict markers
            # (<<<<<<< >>>>>>>) which should trigger conflicts between OURS and
            # THEIRS in the merge tool.
            ancestorPath = str(Path(into, f"[NO-ANCESTOR]{baseName}"))
            shutil.copyfile(targetPath, ancestorPath)

        # Create scratch file (merge tool output).
        # Some merge tools (such as VS Code) use the contents of this file
        # as a starting point, so copy the workdir version for this purpose.
        scratchPath = Path(into, f"[MERGED]{baseName}")
        shutil.copyfile(targetPath, scratchPath)

        paths = MergeDriver.MergeFiles(
            ours=oursPath,
            theirs=theirsPath,
            ancestor=ancestorPath,
            scratch=str(scratchPath),
            target=targetPath)

        mergeDriver = MergeDriver(self.parentWidget(), conflict, paths)

        # Keep a reference to temp dir so it doesn't vanish
        mergeDriver._keepAroundMergeTempDir = mergeTempDir  # type: ignore[attr-defined]

        return mergeDriver


class PreviewDeltaFile(RepoTask):
    def flow(self, df: GitDeltaFile, prefix: str, registerCallback: Callable[[CodeWindow], None]):
        # Don't load large files
        maxFileSize = settings.prefs.largeFileThresholdKB * 1024
        if maxFileSize != 0 and GitDeltaFile.SupportsFastSizeBallpark:
            ballpark = df.sizeBallpark(self.repo)
            if maxFileSize < ballpark:
                locale = QLocale()
                humanSize = locale.formattedDataSize(ballpark, 1)
                message = _("This file is very large.") + f" ({humanSize})"
                yield from self.flowConfirm(_p("ConflictView", "Preview"), message, verb=_("Show anyway"))

        # Dump the file
        path = yield from self.flowSubtask(SaveDeltaFileAs, df, saveIntoDir=qTempDir())
        pathObj = Path(path)
        text = pathObj.read_text("utf-8")
        pathObj.unlink()

        ident = hash(text) ^ hash(df.path)

        # Raise existing window, if any
        if CodeWindow.activateExistingWindow(ident):
            return

        codeWindow = CodeWindow(uniqueIdentifier=ident)
        codeWindow.setPlainText(text, df.path)
        codeWindow.setWindowTitle(f"[{prefix}] {Path(df.path).name}")
        codeWindow.show()
        codeWindow.codeView.setFocus()

        self.rw.aboutToDelete.connect(codeWindow.close)

        registerCallback(codeWindow)


class AcceptMergeConflictResolution(RepoTask):
    def canKill(self, task: RepoTask) -> bool:
        from gitfourchette.tasks import RefreshRepo, Jump
        return isinstance(task, RefreshRepo | Jump)

    def flow(self, mergeDriver: MergeDriver):
        self.epilog.effects |= TaskEffects.Workdir
        path = mergeDriver.conflict.ours.path
        mergeDriver.copyScratchToTarget()
        mergeDriver.deleteNow()
        yield from self.flowCallGit("add", "--force", "--", path)

        # Jump to staged file after confirming conflict resolution
        self.epilog.jumpTo = NavLocator.inStaged(path)
        self.epilog.status = _("Merge conflict resolved in {0}.", tquo(path))


class ApplyPatchFile(RepoTask):
    def flow(self, path: str = ""):
        yield from ApplyPatchFile.do(self, path=path)

    @staticmethod
    def do(
            task: RepoTask,
            path: str = "",
            reverse: bool = False,
            context: int = -1,
            title: str = "",
            question: str = "",
    ):
        # Fallback title
        title = title or task.name()

        # If no path, bring up file dialog
        if not path:
            patchFileCaption = _("Patch file")
            allFilesCaption = _("All files")

            qfd = PersistentFileDialog.openFile(
                task.parentWidget(), "OpenPatch", title,
                filter=f"{patchFileCaption} (*.patch);;{allFilesCaption} (*)")
            path = yield from task.flowFileDialog(qfd)

        # Fallback question
        if not question:
            verb = _("revert") if reverse else _("apply")
            basename = Path(path).name
            question = _("Do you want to {verb} patch file {path}?",
                         verb=btag(verb), path=bquoe(basename))

        # Build command
        stem = [
            "apply",
            *argsIf(reverse, "--reverse"),
            *argsIf(context >= 0, f"-C{context}"),
            path
        ]

        # Do a dry run first.
        driver = yield from task.flowCallGit(*stem, "--numstat", "-z", "--check")

        table = driver.stdoutTableNumstatZ()
        numFiles = len(table)
        details = []
        firstFile = ""
        for adds, dels, patchFile in table:
            if adds == "-" or dels == "-":
                details.append(_("(binary)") + " " + escape(patchFile))
            else:
                details.append(f"(<add>+{adds}</add> <del>-{dels}</del>) {escape(patchFile)}")
            firstFile = firstFile or patchFile

        addDelStyle = settings.prefs.addDelColorsStyleTag()
        listIntro = _n("<b>{n}</b> file will be modified in your working directory:",
                       "<b>{n}</b> files will be modified in your working directory:", n=numFiles)
        confirmText = addDelStyle + paragraphs(question, listIntro)

        yield from task.flowConfirm(title, confirmText, verb=_("Apply"), detailList=details)

        # Dry run confirmed, go ahead
        task.epilog.effects |= TaskEffects.Workdir

        yield from task.flowCallGit(*stem)

        task.epilog.jumpTo = NavLocator.inUnstaged(firstFile)
        task.epilog.status = _n("{n} file modified in the working directory.",
                                "{n} files modified in the working directory.", n=numFiles)


class ApplyPatchFileReverse(RepoTask):
    def flow(self, path: str = ""):
        yield from ApplyPatchFile.do(self, path=path, reverse=True)


class ApplyPatchData(RepoTask):
    def flow(self, patchData: str, title: str, question: str, reverse: bool = False, context: int = -1):
        assert isinstance(patchData, str), "patchData should be str"

        if not patchData:
            raise AbortTask(_("There’s nothing to apply in the selection."))

        template = os.path.join(qTempDir(), self.__class__.__name__ + "-XXXXXX.patch")
        tempPatch = QTemporaryFile(template, self)
        tempPatch.open()
        tempPatch.write(patchData.encode("utf-8"))
        tempPatch.close()
        path = tempPatch.fileName()

        yield from ApplyPatchFile.do(
            self,
            path=path,
            reverse=reverse,
            context=context,
            title=title,
            question=question)


class RestoreRevisionToWorkdir(RepoTask):
    def flow(self, delta: GitDelta, old: bool):
        if old:
            preposition = _p("preposition slotted into '...BEFORE this commit'", "before")
            diffFile = delta.old
            delete = delta.status == GitStatus.Added
        else:
            preposition = _p("preposition slotted into '...AT this commit'", "at")
            diffFile = delta.new
            delete = delta.status == GitStatus.Deleted

        path = self.repo.in_workdir(diffFile.path)
        pathObj = Path(path)
        existsNow = pathObj.is_file()

        if not existsNow and delete:
            message = _("Your working copy of {path} already matches the revision {preposition} this commit.",
                        path=bquo(diffFile.path), preposition=preposition)
            raise AbortTask(message, icon="information")

        if not existsNow:
            actionVerb = _("recreated")
        elif delete:
            actionVerb = _("deleted")
        else:
            actionVerb = _("overwritten")
        prompt = paragraphs(
            _("Do you want to restore {path} as it was {preposition} this commit?",
              path=bquo(diffFile.path), preposition=preposition),
            _("This file will be {processed} in your working directory.", processed=actionVerb))

        yield from self.flowConfirm(text=prompt, verb=_("Restore"))

        self.epilog.effects |= TaskEffects.Workdir

        if delete:
            pathObj.unlink()
        else:
            assert diffFile.sourceCommit not in [None, NULL_OID]
            yield from self.flowCallGit(
                "restore",
                "--progress",
                f"--source={diffFile.sourceCommit}",
                "--",
                diffFile.path)

        self.epilog.status = _("File {path} {processed}.", path=tquoe(diffFile.path), processed=actionVerb)
        self.epilog.jumpTo = NavLocator.inUnstaged(diffFile.path)


class SaveDeltaFileAs(RepoTask):
    def flow(self, file: GitDeltaFile, saveIntoDir: str = "", prefix: str = ""):
        dfPath = Path(file.path)
        suggStem = prefix + dfPath.stem

        if file.source == GitDeltaSource.Commit:
            assert file.sourceCommit not in [None, NULL_OID]
            suggStem += f"@{shortHash(file.sourceCommit)}"
            catFileArgs = ["--filters", f"{file.sourceCommit}:{file.path}"]  # --filters for LFS awareness
        elif file.source == GitDeltaSource.Index:
            catFileArgs = ["--filters", f":{file.path}"]  # --filters for LFS awareness
        elif file.source == GitDeltaSource.Unknown:  # Most likely from GitConflict
            assert file.isIdValid()
            catFileArgs = ["blob", str(file.id)]
        else:
            raise NotImplementedError()

        suggExt = dfPath.suffix
        suggName = suggStem + suggExt

        if saveIntoDir:
            targetStr = withUniqueSuffix(suggStem, ext=suggExt, reserved=lambda s: Path(saveIntoDir, s).exists())
            targetStr = str(Path(saveIntoDir, targetStr))
        else:
            qfd = PersistentFileDialog.saveFile(self.parentWidget(), "SaveFile", _("Save file revision as"), suggName)
            targetStr = yield from self.flowFileDialog(qfd)
        target = Path(targetStr)

        driver = yield from self.flowCallGit("cat-file", *catFileArgs)

        assert not driver._stdout, "stdout consumed prematurely"
        data = driver.readAllStandardOutput().data()
        target.write_bytes(data)

        if file.mode == FileMode.BLOB_EXECUTABLE:
            mode = 0o100 | target.lstat().st_mode
            target.lchmod(mode)

        return str(target)


class SaveRevisionAs(RepoTask):
    def flow(self, delta: GitDelta, old: bool, saveIntoDir: str = "", prefix: str = ""):
        if old:
            diffFile = delta.old
            if delta.status == GitStatus.Added:
                raise AbortTask(_("This file didn’t exist before the commit."))
        else:
            diffFile = delta.new
            if delta.status == GitStatus.Deleted:
                raise AbortTask(_("This file was deleted by the commit."))

        outPath = yield from self.flowSubtask(SaveDeltaFileAs, diffFile, saveIntoDir, prefix)
        return outPath


class OpenRevisionInEditor(RepoTask):
    def flow(self, delta: GitDelta, old: bool):
        path = yield from self.flowSubtask(SaveRevisionAs, delta, old, qTempDir())
        assert isinstance(path, str)

        ToolProcess.startTextEditor(self.parentWidget(), path)


class OpenInDiffTool(RepoTask):
    def flow(self, delta: GitDelta):
        if delta.new.isId0():
            raise FileNotFoundError(_("Can’t open external diff tool on a deleted file."))

        if delta.old.isId0():
            raise FileNotFoundError(_("Can’t open external diff tool on a new file."))

        into = qTempDir()

        def dump(file: GitDeltaFile, prefix: str):
            path = yield from self.flowSubtask(SaveDeltaFileAs, file, saveIntoDir=into, prefix=prefix)
            return path

        if delta.source == GitDeltaSource.Dirty:
            # Unstaged: compare indexed state to workdir file
            oldPath = yield from dump(delta.old, "[INDEXED]")
            newPath = self.repo.in_workdir(delta.new.path)
        elif delta.source == GitDeltaSource.Index:
            # Staged: compare HEAD state to indexed state
            oldPath = yield from dump(delta.old, "[HEAD]")
            newPath = yield from dump(delta.new, "[STAGED]")
        elif delta.source == GitDeltaSource.Commit:
            # Committed: compare parent state to this commit
            oldPath = yield from dump(delta.old, "[OLD]")
            newPath = yield from dump(delta.new, "[NEW]")
        else:
            raise NotImplementedError(f"unsupported source {delta.source}")

        return ToolProcess.startDiffTool(self.parentWidget(), oldPath, newPath)


class AbortMerge(RepoTask):
    def flow(self):
        self.repo.refresh_index()

        isMerging = self.repo.state() == RepositoryState.MERGE
        isCherryPicking = self.repo.state() == RepositoryState.CHERRYPICK
        isReverting = self.repo.state() == RepositoryState.REVERT
        anyConflicts = self.repo.index.conflicts

        if not (isMerging or isCherryPicking or isReverting or anyConflicts):
            raise AbortTask(_("No abortable state is in progress."), icon='information')

        if isCherryPicking:
            clause = _("abort the ongoing cherry-pick")
            title = _("Abort cherry-pick")
            postStatus = _("Cherry-pick aborted.")
            gitCommand = ["cherry-pick", "--abort"]
        elif isMerging:
            clause = _("abort the ongoing merge")
            title = _("Abort merge")
            postStatus = _("Merge aborted.")
            gitCommand = ["merge", "--abort"]
        elif isReverting:
            clause = _("abort the ongoing revert")
            title = _("Abort revert")
            postStatus = _("Revert aborted.")
            gitCommand = ["revert", "--abort"]
        else:
            clause = _("reset the index")
            title = _("Reset index")
            postStatus = _("Index reset.")
            gitCommand = ["reset", "--merge"]

        try:
            abortList = self.repo.get_reset_merge_file_list()
        except MultiFileError as exc:
            exc.message = _n(
                "Cannot {verb} right now, because a file contains both staged and unstaged changes.",
                "Cannot {verb} right now, because {n} files contain both staged and unstaged changes.",
                n=len(exc.file_exceptions), verb=clause)
            exc.message += " " + _("Please unstage the changes and try again.")
            raise  # re-raise MultiFileError

        lines = [_("Do you want to {0}?", clause)]

        if not abortList:
            lines.append(_("No files are affected."))
        else:
            if anyConflicts:
                lines.append(_("All conflicts will be cleared and all <b>staged</b> changes will be lost."))
            else:
                lines.append(_("All <b>staged</b> changes will be lost."))
            lines.append(_n("This file will be reset:", "{n} files will be reset:", len(abortList)))

        yield from self.flowConfirm(title=title, text=paragraphs(*lines), verb=englishTitleCase(title),
                                    detailList=[escape(f) for f in abortList])

        self.epilog.effects |= TaskEffects.DefaultRefresh

        yield from self.flowCallGit(*gitCommand)
        # self.repo.reset_merge()
        # self.repo.state_cleanup()

        self.epilog.status = postStatus

        # Clear draft commit message that was set in CherrypickCommit/RevertCommit/MergeBranch
        if isCherryPicking or isReverting or isMerging:
            self.repoModel.prefs.clearDraftCommit()
