# -----------------------------------------------------------------------------
# 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.
# -----------------------------------------------------------------------------

"""
Tasks that navigate to a specific area of the repository.

Unlike most other tasks, jump tasks directly manipulate the UI extensively, via RepoWidget.
"""

import dataclasses
import logging
import os
import re

from gitfourchette import trtables
from gitfourchette import settings
from gitfourchette.diffview.diffdocument import DiffDocument
from gitfourchette.diffview.specialdiff import SpecialDiffError, ImageDelta
from gitfourchette.gitdriver import GitConflict, GitDelta, GitStatus, GitDriver, argsIf
from gitfourchette.gitdriver.parsers import parseAheadBehind
from gitfourchette.graphview.commitlogmodel import SpecialRow
from gitfourchette.localization import *
from gitfourchette.nav import NavLocator, NavContext, NavFlags
from gitfourchette.porcelain import NULL_OID, Oid, commit_diff_pair
from gitfourchette.qt import *
from gitfourchette.repomodel import UC_FAKEREF, UC_FAKEID
from gitfourchette.tasks import TaskPrereqs
from gitfourchette.tasks.loadtasks import LoadPatch, TAbstractDiffDocument
from gitfourchette.tasks.repotask import AbortTask, RepoTask, TaskEffects, RepoGoneError
from gitfourchette.toolbox import *

logger = logging.getLogger(__name__)

_submoduleIndexLinePattern = re.compile(r"^index ([\da-f]+)\.\.([\da-f]+)", re.MULTILINE)


def loadWorkdir(task: RepoTask, allowWriteIndex: bool):
    """
    Refresh staged/dirty GitDeltas in the RepoModel.
    """

    # Get oid of the head commit (or None if unborn).
    # It will be stored in GitDeltaFile.old in staged files.
    if task.repo.head_is_unborn:
        headCommitId = None
    else:
        headCommitId = task.repo.head_commit_id

    # Run 'git status'
    gitStatus = yield from task.flowCallGit(
        *argsIf(not allowWriteIndex, "--no-optional-locks"),
        "status",
        "--porcelain=v2",
        "-z",
        "--untracked-files=all")

    # Get GitDelta lists from 'git status' output
    numEntries, stagedDeltas, unstagedDeltas = gitStatus.readStatusPorcelainV2Z(headCommitId)

    if APP_DEBUG:
        assert not any(d.submoduleStatus.startswith("S") for d in stagedDeltas), "only expecting full submo status in unstaged deltas"

    # Pre-cache LFS state for unstaged files via `git check-attr` if
    # .gitattributes has any unstaged changes.
    # LFS state is usually queried via libgit2's git_get_attr. However, it's
    # unreliable when .gitattributes has unstaged changes, because it falls back
    # to the indexed revision of .gitattributes when an attr was deleted from
    # the file but still exists in the index. Ideally, libgit2 should offer a
    # GIT_ATTR_CHECK_FILE_ONLY flag so we don't have to do this.
    with Benchmark("check-attr unstaged"):
        if any(d.new.path.endswith(".gitattributes") for d in unstagedDeltas):
            unstagedTable = {d.new.path: d for d in unstagedDeltas}

            checkAttrDriver = yield from task.flowCallGit(
                "check-attr", "-z", "filter", "--", *unstagedTable.keys())

            # Can't use stdoutTable because all fields are zero-separated.
            tokens = checkAttrDriver.stdoutScrollback().removesuffix("\0").split("\0")
            for i in range(0, len(tokens), 3):
                # (filename, attribute, unspecified/unset/set/value) triplets
                fileName = tokens[i]
                value = tokens[i + 2]
                delta = unstagedTable[fileName]
                delta.new.cacheLfsPointer(task.repo, value)

    # Fill in submodule commit hashes.
    # (This might be parallelizable if we've got tons of modified submodules)
    for delta in unstagedDeltas:
        # Scan for submodules with changes that the superproject isn't tracking yet
        submoduleUpdated = delta.submoduleStatus.startswith("S")
        if not submoduleUpdated:
            continue

        if delta.status == GitStatus.Deleted:
            assert delta.new.isId0()
            continue

        # Work out head commit for this submodule
        headDidMove = "C" in delta.submoduleStatus
        if not headDidMove:
            # The submodule's head hasn't moved.
            submoduleCommitHash = delta.old.id
        else:
            # The submodule's head has moved, but we don't know to what commit,
            # because "git status" doesn't give this information if unstaged.
            subDiffDriver = yield from task.flowCallGit("diff", "--submodule=short", "--full-index", delta.new.path)
            subDiff = subDiffDriver.stdoutScrollback()
            match = _submoduleIndexLinePattern.search(subDiff)
            assert match
            assert delta.old.id == match.group(1)
            submoduleCommitHash = match.group(2)

        # Fill in worktree hash
        assert not delta.new.isIdValid(), f"not expecting id to be filled in right after git status: {delta.new.id}"
        delta.new.id = submoduleCommitHash

    repoModel = task.repoModel
    repoModel.workdirUnstagedDeltas = unstagedDeltas
    repoModel.workdirStagedDeltas = stagedDeltas
    repoModel.workdirNumChanges = numEntries
    repoModel.workdirStatusReady = True

    # Update pathspec filter
    cpf = repoModel.commitPathspecFilter
    if not cpf.needle:
        pass
    elif repoModel.workdirMatchesPathNeedle(cpf.needle):
        if UC_FAKEID not in cpf.matchingIds:
            cpf.matchingIds.add(UC_FAKEID)
            cpf.resultsUpdated.emit()
    else:
        if UC_FAKEID in cpf.matchingIds:
            cpf.matchingIds.remove(UC_FAKEID)
            cpf.resultsUpdated.emit()

    # Refresh libgit2 index after git status is complete
    repoModel.repo.refresh_index()


class Jump(RepoTask):
    """
    Single entry point to navigate to any NavLocator in a repository.

    This is the only task that is allowed to change the value of RepoWidget.navLocator.
    """

    @dataclasses.dataclass
    class Result(Exception):
        locator: NavLocator
        document: TAbstractDiffDocument | None
        delta: GitDelta | None = None

    def canKill(self, task: RepoTask):
        return isinstance(task, Jump
                          | JumpBack
                          | JumpForward
                          | JumpToHEAD
                          | JumpToUncommittedChanges
                          | RefreshRepo)

    def flow(self, locator: NavLocator):
        if not locator:
            return

        # Back up current locator
        self.rw.saveFilePositions()

        result = yield from self.loadResult(locator)

        assert onAppThread()
        self.saveFinalLocator(result.locator)
        self.displayResult(result)

        if locator.hasFlags(NavFlags.ActivateWindow):  # initial locator!
            self.rw.activateWindow()

    def loadResult(self, locator: NavLocator) -> RepoTask.Flow[Result]:
        rw = self.rw

        # If the locator is "coarse" (i.e. no specific path given, just a generic context),
        # try to recall where we were last time we looked at this context.
        locator = rw.navHistory.refine(locator)

        try:
            # Load workdir or commit, and show the corresponding view
            if locator.context == NavContext.SPECIAL:
                self.showSpecial(locator)  # always raises Jump.Result
            elif locator.context.isWorkdir():
                locator = yield from self.showWorkdir(locator)
            else:
                locator = yield from self.showCommit(locator)
        except Jump.Result as result:
            # The showXXX functions may bail early by raising Jump.Result.
            # Set up DiffArea for this locator.
            rw.diffArea.setUpForLocator(result.locator)
            return result

        fileList = rw.diffArea.fileListByContext(locator.context)

        # If we don't have a path in the locator, fall back to first path in file list.
        # (Only for non-special locators, though!)
        if not locator.path and locator.context != NavContext.SPECIAL:
            locator = locator.replace(path=fileList.firstPath())
            locator = rw.navHistory.refine(locator)

        # Set up DiffArea for this locator.
        # Note that this may return a new locator if the desired path is not available.
        locator = rw.diffArea.setUpForLocator(locator)

        # Blank path?
        if not locator.path:
            return Jump.Result(locator, None)

        delta = fileList.deltaForFile(locator.path)
        assert locator.context == NavContext.fromGitDeltaSource(delta.source)

        # If DiffView is already set up to display this specific patch,
        # we don't need to bother shelling out to 'git diff'.
        # TODO: Would also be nice for image diffs!
        if self.isDiffViewAlreadySetUpFor(locator, delta):
            return Jump.Result(rw.diffView.currentLocator, rw.diffView.currentDiffDocument, delta)

        # Load the patch
        diffDocument = yield from self.flowSubtask(LoadPatch, delta, locator)
        return Jump.Result(locator, diffDocument, delta)

    def isDiffViewAlreadySetUpFor(self, locator: NavLocator, delta: GitDelta) -> bool:
        currentLocator = self.rw.diffView.currentLocator
        currentDelta = self.rw.diffView.currentDelta

        # Special flag to bypass same-patch detection
        if locator.hasFlags(NavFlags.ForceRecreateDocument):
            return False

        # Coarse NavLocators must match
        if not locator.isSimilarEnoughTo(currentLocator):
            return False

        # Special case for unstaged files: a valid hash may not be available
        # in delta.new, so we rely on the file's stats on disk.
        if locator.context == NavContext.UNSTAGED:
            if delta.new.path != currentDelta.new.path:
                return False

            # Refresh filesystem status for unstaged files. Since `git status`
            # doesn't hash unstaged files, we rely on st_mtime_ns to determine if
            # we're displaying stale contents.
            if currentDelta.new.diskStat != currentDelta.new.stat(self.repo):
                return False

            # If all else is equal, equality hinges on the old delta.
            # Ignore the new delta, because DiffView may have a valid new hash.
            # We can expect the old hash (i.e. the blob in the index) to be valid.
            assert delta.old.isIdValid()
            return delta.old == currentDelta.old

        # In staged or commit contexts, we've got valid hashes for both sides.
        assert delta.old.isIdValid()
        assert delta.new.isIdValid()
        assert currentDelta.old.isIdValid()
        assert currentDelta.new.isIdValid()

        return delta == currentDelta

    def showWorkdir(self, locator: NavLocator) -> RepoTask.Flow[NavLocator]:
        rw = self.rw
        repoModel = self.repoModel

        # Save selected row number for the end of the function
        previousRowStaged = rw.stagedFiles.earliestSelectedRow()
        previousRowDirty = rw.dirtyFiles.earliestSelectedRow()

        with (
            QSignalBlockerContext(rw.sidebar),  # Don't emit jump signals
            QScrollBackupContext(rw.sidebar),  # Stabilize scroll bar value
        ):
            rw.sidebar.selectAnyRef(UC_FAKEREF)

        self.showLocatorInGraphView(locator)

        # Reset diff banner
        rw.diffArea.diffBanner.setVisible(False)
        rw.diffArea.contextHeader.setContext(locator)

        # Stale workdir model - force load workdir
        forceDiff = locator.hasFlags(NavFlags.ForceDiff)
        writeIndex = locator.hasFlags(NavFlags.AllowWriteIndex)
        if forceDiff or repoModel.workdirStale:
            # Load workdir (async)
            if forceDiff or not repoModel.workdirStatusReady:
                yield from loadWorkdir(self, allowWriteIndex=writeIndex)

            # Fill FileListViews
            with QSignalBlockerContext(rw.dirtyFiles, rw.stagedFiles):  # Don't emit jump signals
                rw.dirtyFiles.setContents(repoModel.workdirUnstagedDeltas)
                rw.stagedFiles.setContents(repoModel.workdirStagedDeltas)

            # Invalidate ConflictView if its current conflict is gone
            cc = rw.conflictView.currentConflict
            if not any(cc == d.conflict for d in repoModel.workdirUnstagedDeltas):
                rw.conflictView.invalidate()

            # Update file counts in captions
            nDirty = rw.dirtyFiles.model().rowCount()
            nStaged = rw.stagedFiles.model().rowCount()
            rw.diffArea.dirtyHeader.setText(_n("Unstaged ({n})", "Unstaged ({n})", nDirty))
            rw.diffArea.stagedHeader.setText(_n("Staged ({n})", "Staged ({n})", nStaged))
            rw.diffArea.commitButton.setText(_n("Commit {n} file", "Commit {n} files", nStaged))

            # Make Commit button bold if anything is staged
            commitButtonFont = rw.diffArea.commitButton.font()
            commitButtonBold = nStaged != 0
            if commitButtonFont.bold() != commitButtonBold:
                commitButtonFont.setBold(commitButtonBold)
                rw.diffArea.commitButton.setFont(commitButtonFont)

            # Flip workdir freshness
            repoModel.workdirStale = False
            repoModel.workdirStatusReady = True

        # If jumping to generic workdir context, find a concrete context
        if locator.context == NavContext.WORKDIR:
            if rw.dirtyFiles.isEmpty() and not rw.stagedFiles.isEmpty():
                locator = locator.replace(context=NavContext.STAGED)
            else:
                locator = locator.replace(context=NavContext.UNSTAGED)
            locator = rw.navHistory.refine(locator)

        # Early out if workdir is clean
        if rw.dirtyFiles.isEmpty() and rw.stagedFiles.isEmpty():
            locator = locator.replace(path="")
            sde = SpecialDiffError(
                _("The working directory is clean."),
                _("There aren’t any changes to commit."))
            raise Jump.Result(locator, sde)

        assert not locator.hasFlags(NavFlags.FuzzyPath), "FuzzyPath should not occur in the workdir"

        # (Un)Staging a file makes it vanish from its file list.
        # But we don't want the selection to go blank in this case.
        # Restore selected row (by row number) in the file list so the user
        # can keep hitting RETURN/DELETE to stage/unstage a series of files.
        isStaged = locator.context == NavContext.STAGED
        flModel = (rw.stagedFiles if isStaged else rw.dirtyFiles).flModel
        flPrevRow = previousRowStaged if isStaged else previousRowDirty

        if locator.path and not flModel.hasFile(locator.path) and flPrevRow >= 0:
            path = flModel.getFileAtRow(min(flPrevRow, flModel.rowCount()-1))
            locator = locator.replace(path=path)
            locator = locator.coarse(keepFlags=True)  # don't carry cursor over from old locator
            locator = rw.navHistory.refine(locator)

        return locator

    def showSpecial(self, locator: NavLocator):
        rw = self.rw
        locale = QLocale()

        with QSignalBlockerContext(rw.sidebar, rw.committedFiles):
            rw.sidebar.clearSelection()
            rw.diffArea.committedFiles.clear()
            rw.diffArea.committedHeader.setText(" ")
            rw.diffArea.diffBanner.hide()
            rw.diffArea.contextHeader.setContext(locator)

        self.showLocatorInGraphView(locator)

        special = SpecialRow.fromString(locator.path)

        if special == SpecialRow.EndOfShallowHistory:
            sde = SpecialDiffError(
                _("Shallow clone – End of available history."),
                _("More commits may be available in a full clone."))

        elif special == SpecialRow.TruncatedHistory:
            from gitfourchette import settings
            expandSome = makeInternalLink("expandlog")
            expandAll = makeInternalLink("expandlog", n=str(0))
            changePref = makeInternalLink("prefs", "maxCommits")
            humanNextThreshold = locale.toString(self.repoModel.nextTruncationThreshold)
            humanPrefThreshold = locale.toString(settings.prefs.maxCommits)
            options = [
                linkify(_("Load up to {0} commits", humanNextThreshold), expandSome),
                linkify(_("[Load full commit history] (this may take a moment)"), expandAll),
                linkify(_("[Change threshold setting] (currently: {0} commits)", humanPrefThreshold), changePref),
            ]
            sde = SpecialDiffError(
                _("History truncated to {0} commits.", locale.toString(self.repoModel.numRealCommits)),
                _("More commits may be available."),
                longform=toRoomyUL(options))

        elif special == SpecialRow.CannotCompareRows:
            sde = SpecialDiffError(
                _("The selected items cannot be compared."),
                icon="SP_MessageBoxWarning")

        elif special == SpecialRow.TooManyRowsSelected:
            controlKey = QKeySequence(Qt.Key.Key_Control).toString(QKeySequence.SequenceFormat.NativeText)
            numRows = len(locator.selectedCommits)
            sde = SpecialDiffError(
                _n("{n} item selected", "{n} items selected", n=numRows),
                _("You can compare up to two commits at a time. Tip: hold {0} "
                  "while making a selection to compare 2 discontiguous commits.", controlKey))

        else:
            raise NotImplementedError(f"Unsupported special locator: {special}")

        raise Jump.Result(locator, sde)

    def showCommit(self, locator: NavLocator) -> RepoTask.Flow[NavLocator]:
        """
        Jump to a commit.
        Return a refined NavLocator.
        """

        rw = self.rw
        area = rw.diffArea
        assert locator.context == NavContext.COMMITTED

        # If it's a ref, look it up
        if locator.ref:
            assert locator.commit == NULL_OID
            try:
                oid = self.repoModel.refs[locator.ref]
                locator = locator.replace(commit=oid, ref="")
            except KeyError as exc:
                raise AbortTask(_("Unknown reference {0}.", tquo(locator.ref))) from exc

        assert locator.commit
        assert not locator.ref

        try:
            stashIndex = rw.repoModel.stashes.index(locator.commit)
            isStash = True
        except ValueError:
            stashIndex = -1
            isStash = False

        commit = rw.repo.peel_commit(locator.commit)

        # Attempt to select matching ref in sidebar
        with (
            QSignalBlockerContext(rw.sidebar),  # Don't emit jump signals
            QScrollBackupContext(rw.sidebar),  # Stabilize scroll bar value
        ):
            if isStash:
                rw.sidebar.selectAnyRef(f"stash@{{{stashIndex}}}")
            else:
                refCandidates = rw.repoModel.refsAt.get(locator.commit, [])
                rw.sidebar.selectAnyRef(*refCandidates)

        flv = area.committedFiles
        area.diffBanner.setVisible(False)
        area.contextHeader.setContext(locator, commit.message, isStash)

        # Attempt to show the commit in GraphView
        self.showLocatorInGraphView(locator, isStash=isStash)

        if (not locator.hasFlags(NavFlags.ForceDiff)
                and locator.commit == rw.navLocator.commit
                and locator.selectedCommits == rw.navLocator.selectedCommits):
            # No need to reload the same commit diff
            logger.debug("Don't reload same commit diff")

        else:
            # Loading a different commit
            area.diffBanner.lastWarningWasDismissed = False

            # Load commit
            diffAB = locator.commitDiffAB()
            if not diffAB:
                diffAB = commit_diff_pair(commit)
            tokens = GitDriver.buildDiffRawCommand(diffAB)
            driver = yield from self.flowCallGit(*tokens)
            deltas = driver.readDiffRawZ()

            # Fill out source commits
            for d in deltas:
                d.old.sourceCommit = diffAB[0]
                d.new.sourceCommit = diffAB[1]

            summary = self.repo.peel_commit(locator.commit).message.strip()

            # Fill committed file list
            with QSignalBlockerContext(flv):  # Don't emit jump signals
                flv.clear()
                flv.setCommitLocator(locator)
                flv.setContents(deltas)
                numChanges = flv.model().rowCount()

            # Set header text
            headerText = toLengthVariants(_n("{n} change:|{n} ch.:", "{n} changes:|{n} ch.:", numChanges))
            area.committedHeader.setText(headerText)
            area.committedHeader.setToolTip("<p>" + escape(summary).replace("\n", "<br>"))

        # Early out if the commit is empty
        if flv.isEmpty():
            if locator.commitDiffAB():
                a, b = locator.commitDiffAB()
                message = _("No changes from {0} to {1}.", hquo(shortHash(a)), hquo(shortHash(b)))
                details = _("The trees are identical at both commits.")
            else:
                message = _("This commit is empty.")
                details = _("Commit {0} doesn’t affect any files.", hquo(shortHash(locator.commit)))
            sde = SpecialDiffError(message, details)
            locator = locator.replace(path="")
            raise Jump.Result(locator, sde)

        # Try to resolve a fuzzy path
        if locator.path and locator.hasFlags(NavFlags.FuzzyPath):
            resolvedPath = flv.flModel.matchPathspec(locator.path)
            locator = locator.replace(path=resolvedPath)
            locator = locator.withoutFlags(NavFlags.FuzzyPath)

        return locator

    def saveFinalLocator(self, locator: NavLocator):
        # Before saving the locator in the RepoWidget, strip one-time flags.
        locator = locator.withoutFlags(~NavFlags.KeepFlagsOnRefresh)

        self.rw.navLocator = locator

        self.rw.navHistory.push(locator)
        self.rw.historyChanged.emit()

    def showLocatorInGraphView(self, locator: NavLocator, isStash=False):
        graphView = self.rw.graphView

        with QSignalBlockerContext(graphView):  # Don't emit jump signals
            try:
                graphView.selectRowForLocator(locator)
            except graphView.SelectCommitError as e:
                # Commit is hidden or not loaded
                graphView.clearSelection()

                # Warning banner
                banner = self.rw.diffArea.diffBanner
                if not banner.lastWarningWasDismissed and not isStash:
                    warningText = str(e)
                    banner.popUp("", warningText, canDismiss=True, withIcon=True)

    def displayResult(self, result: Result):
        document = result.document
        area = self.rw.diffArea

        # Set header
        header = self.makeHeaderText(result.document, result.delta, result.locator)
        area.diffHeader.setText(header)

        # Document-specific controls
        showSvgButton = (result.delta is not None
                         and result.delta.new.path.lower().endswith(".svg")
                         and isImageFormatSupported("file.svg"))
        area.diffButtons.svgButton.setVisible(showSvgButton)

        # Set document
        if document is None:
            area.clearDocument(result.locator)

        elif isinstance(document, DiffDocument):
            assert result.delta is not None
            area.setDiffStackPage("text")
            area.diffView.replaceDocument(self.repo, result.delta, result.locator, document)

        elif isinstance(document, GitConflict):
            conflict = document
            area.setDiffStackPage("conflict")
            area.conflictView.displayConflict(conflict)

        elif isinstance(document, SpecialDiffError):
            area.setDiffStackPage("special")
            area.specialDiffView.displaySpecialDiffError(document)

        elif isinstance(document, ImageDelta):
            area.setDiffStackPage("special")
            area.specialDiffView.displayImageDelta(document)

        else:
            raise NotImplementedError(f"Can't display {type(document)}")

    @staticmethod
    def makeHeaderText(
            document: TAbstractDiffDocument | None,
            delta: GitDelta | None,
            locator: NavLocator
    ) -> str:
        if (document is None
                or delta is None
                or locator.context == NavContext.SPECIAL):
            return ""

        details = []

        if locator.context.isWorkdir():
            contextName = trtables.enum(locator.context).lower()
            details.append(contextName)
        elif locator.context == NavContext.COMMITTED:
            diffAB = locator.commitDiffAB()
            if diffAB:
                details.append(f"{shortHash(diffAB[0])}...{shortHash(diffAB[1])}")
            else:
                details.append(_p("at (specific commit)", "at {0}", shortHash(locator.commit)))

        if not delta.new.lfs.isTentative():
            if delta.new.lfs and not delta.old.lfs:
                details.append(tagify(_("LFS pointer [added]"), "<add>"))
            elif not delta.new.lfs and delta.old.lfs:
                details.append(tagify(_("LFS pointer [removed]"), "<del>"))
            elif delta.new.lfs and delta.old.lfs:
                details.append(_("LFS pointer changed"))
        elif delta.old.lfs:
            details[0] = _("unstaged changes to LFS object")

        # Compose final message
        parts = ["<html>", settings.prefs.addDelColorsStyleTag(), escape(locator.path)]
        if isinstance(document, DiffDocument):
            if document.pluses:
                parts.append(f" <b><add>+{document.pluses}</add></b>")
            if document.minuses:
                parts.append(f" <b><del>-{document.minuses}</del></b>")
        if details:
            suffix = ", ".join(details)
            parts.append(f" <span style='color: gray;'>({suffix})</span>")
        return "".join(parts)

    @classmethod
    def _jumpDelta(cls, task: RepoTask, delta: int):
        """
        Navigate back or forward in the RepoWidget's NavHistory.
        """

        assert delta in [-1, 1], "illegal delta value"

        rw = task.rw

        # Get starting point
        rw.saveFilePositions()
        start = rw.navLocator

        # Work on a copy of the history while we jump back/forward.
        history = rw.navHistory.copy()

        # Keep jumping back (or forward) in the history until the current
        # locator differs from the starting point, or until the history is
        # exhausted.
        while history.canGoDelta(delta):
            # Move back or forward in the history
            locator = history.navigateDelta(delta)

            # Keep going if same file comes up several times in a row
            if locator.isSimilarEnoughTo(start):
                continue

            # Do the jump. This may be a no-op if the locator is stale.
            yield from task.flowSubtask(cls, locator)

            # The jump was successful if the RepoWidget's locator
            # comes out similar enough to the one from the history.
            if rw.navLocator.isSimilarEnoughTo(locator):
                break

            # This point in history is stale, nuke it and keep going
            history.popCurrent()

        # Finalize history
        history.push(rw.navLocator)
        rw.navHistory = history
        rw.historyChanged.emit()


class JumpBack(RepoTask):
    def flow(self):
        yield from Jump._jumpDelta(self, -1)


class JumpForward(RepoTask):
    def flow(self):
        yield from Jump._jumpDelta(self, 1)


class JumpToUncommittedChanges(RepoTask):
    def flow(self):
        locator = NavLocator.inWorkdir()
        yield from self.flowSubtask(Jump, locator)


class JumpToHEAD(RepoTask):
    def prereqs(self) -> TaskPrereqs:
        return TaskPrereqs.NoUnborn

    def flow(self):
        locator = NavLocator.inRef("HEAD")
        yield from self.flowSubtask(Jump, locator)


class RefreshRepo(RepoTask):
    @staticmethod
    def canKill_static(task: RepoTask):
        return task is None or isinstance(task, Jump | RefreshRepo)

    def canKill(self, task: RepoTask):
        return RefreshRepo.canKill_static(task)

    def onError(self, exc: Exception):
        # Don't refresh again if this task was interrupted by an error.
        # Note that the effects will be kept until the next refresh if this
        # task was killed by another task (this doesn't count as an error).
        self.epilog.effects = TaskEffects.Nothing

        super().onError(exc)

    def flow(self, effectFlags: TaskEffects = TaskEffects.DefaultRefresh, jumpTo: NavLocator = NavLocator.Empty):
        rw = self.rw
        repoModel = self.repoModel
        assert onAppThread()

        if effectFlags == TaskEffects.Nothing:
            return

        # Early out if repo has gone missing
        if not os.path.isdir(self.repo.path):
            raise RepoGoneError(self.repo.path)

        # Accumulate effect bits until task is complete or interrupted by an error
        self.epilog.effects |= effectFlags

        repoModel.workdirStale |= bool(effectFlags & TaskEffects.Workdir)

        initialLocator = rw.navLocator
        initialGraphScroll = rw.graphView.verticalScrollBar().value()
        restoringInitialLocator = jumpTo.context == NavContext.EMPTY
        wasExploringDetachedCommit = initialLocator.commit and initialLocator.commit not in repoModel.graph.commitRows

        jumpTo = jumpTo or initialLocator
        pNumUncommittedChanges = repoModel.numUncommittedChanges

        try:
            previousFileList = rw.diffArea.fileListByContext(initialLocator.context)
            previousFileList.backUpSelection()
        except ValueError:
            previousFileList = None

        refsChanged = False
        stashesChanged = False
        submodulesChanged = False
        remotesChanged = False
        upstreamsChanged = False
        homeBranchChanged = False

        if effectFlags & TaskEffects.Head:
            # Refresh the index. Useful in vanilla git mode: git may have touched
            # the index file during the task, so make libgit2 aware of it.
            repoModel.repo.refresh_index()

        if effectFlags & (TaskEffects.Head | TaskEffects.Workdir):
            submodulesChanged = repoModel.syncSubmodules()

        if effectFlags & (TaskEffects.Refs | TaskEffects.Remotes):
            remotesChanged = repoModel.syncRemotes()

        if effectFlags & (TaskEffects.Refs | TaskEffects.Upstreams):
            upstreamsChanged = repoModel.syncUpstreams()

        if effectFlags & (TaskEffects.Refs | TaskEffects.Remotes | TaskEffects.Head):
            # Refresh ref cache
            oldRefs = repoModel.refs
            oldHeadBranch = repoModel.homeBranch

            refsChanged = repoModel.syncRefs()
            refsChanged |= repoModel.syncMergeheads()
            stashesChanged = repoModel.syncStashes()
            homeBranchChanged = oldHeadBranch != repoModel.homeBranch

            # Load commits from changed refs only
            if refsChanged:
                self.syncTopOfGraph(oldRefs)

        # Refresh ahead/behind. Although Repository.ahead_behind exists, it's
        # slower than "git --for-each-ref" in complex graphs.
        # TODO: Would it be faster to calc this with our pre-cached graph?
        if refsChanged or upstreamsChanged:
            # TODO: This bit is duplicated with PrimeRepo
            with Benchmark("ahead-behind"):
                driver = yield from self.flowCallGit("for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads")
                repoModel.aheadBehind = dict(parseAheadBehind(driver.stdoutScrollback()))

        # Schedule a repaint of the entire GraphView if the refs changed
        if effectFlags & (TaskEffects.Head | TaskEffects.Refs):
            rw.graphView.viewport().update()

        # Refresh sidebar
        rw.sidebar.backUpSelection()
        anyChanges = refsChanged | stashesChanged | submodulesChanged | remotesChanged | homeBranchChanged | upstreamsChanged
        if anyChanges:
            with QSignalBlockerContext(rw.sidebar):
                rw.sidebar.refresh(repoModel)

        # Now jump to where we should be after the refresh
        assert rw.navLocator == initialLocator, "locator has changed"

        jumpToWorkdir = jumpTo.context.isWorkdir() or (jumpTo.context == NavContext.EMPTY and initialLocator.context.isWorkdir())

        if jumpToWorkdir:
            # Refresh workdir view on separate thread AFTER all the processing above
            if not jumpTo.context.isWorkdir():
                jumpTo = NavLocator(NavContext.WORKDIR)

            if effectFlags & TaskEffects.Workdir:
                newFlags = jumpTo.flags | NavFlags.ForceDiff | NavFlags.AllowWriteIndex
                jumpTo = jumpTo.replace(flags=newFlags)

        elif initialLocator and initialLocator.context == NavContext.COMMITTED:
            # After inserting/deleting rows in the commit log model,
            # the selected row may jump around. Try to restore the initial
            # locator to ensure the previously selected commit stays selected.
            rw.graphView.verticalScrollBar().setValue(initialGraphScroll)
            if (jumpTo == initialLocator
                    and jumpTo.commit not in repoModel.graph.commitRows
                    and not wasExploringDetachedCommit):
                # We were looking at a commit that is not in the graph anymore.
                # Probably refreshing after amending. Jump to HEAD commit.
                jumpTo = NavLocator.inCommit(repoModel.headCommitId)

        # Jump
        yield from self.flowSubtask(Jump, jumpTo)

        # Try to restore sidebar selection
        if restoringInitialLocator:
            rw.sidebar.restoreSelectionBackup()
        else:
            rw.sidebar.clearSelectionBackup()

        # Try to restore path selection
        if previousFileList is None:
            pass
        elif restoringInitialLocator:
            previousFileList.restoreSelectionBackup()
        else:
            previousFileList.clearSelectionBackup()

        # If workdir is still stale (refreshing without explicitly looking at the workdir), refresh it after everything else.
        # This is done last so that it doesn't impede on responsivity when the user isn't explicitly looking at the workdir.
        if repoModel.workdirStale:
            assert not jumpToWorkdir, "jumping to workdir should have refreshed the workdir!"
            yield from loadWorkdir(self, jumpTo.hasFlags(NavFlags.AllowWriteIndex))

        # Update number of staged changes in sidebar and graph
        if repoModel.numUncommittedChanges != pNumUncommittedChanges:
            rw.refreshNumUncommittedChanges()

        # Refresh window title and state banner.
        # Do this last because it requires the index to be fresh (updated by LoadWorkdir)
        rw.refreshWindowTitle()
        rw.refreshBanner()

        # Finally, clear out the effect bits that were accumulated by
        # RepoWidget.refreshRepo. Do this *last* because we want to keep the
        # effect bits for the next RefreshRepo task if this one was interrupted
        # by another task. Note that interrupting by an error will flush the bits
        # so we don't get stuck in a refresh loop.
        self.epilog.effects = TaskEffects.Nothing

        if anyChanges:
            logger.debug(f"Changes detected on refresh: Ref={int(refsChanged)} Sta={int(stashesChanged)} "
                         f"Sub={int(submodulesChanged)} Rem={int(remotesChanged)} Ups={int(upstreamsChanged)}")

    def syncTopOfGraph(self, oldRefs: dict[str, Oid]):
        repoModel = self.repoModel
        graphView = self.rw.graphView
        clModel = graphView.clModel
        clFilter = graphView.clFilter

        # Make sure we're on the UI thread.
        # We don't want GraphView to try to read an incomplete state while repainting.
        assert onAppThread()

        # Update our graph model
        gsl = repoModel.syncTopOfGraph(oldRefs)

        with QSignalBlockerContext(graphView):
            # Sync top of graphview
            clModel.resetCommitSequence(gsl.numRowsRemoved, gsl.numRowsAdded)

            # Hidden commits may have changed in RepoModel.syncTopOfGraph!
            # If new commits are part of a hidden branch, we must invalidate CommitLogFilter.
            clFilter.updateHiddenCommits()
