diff --git a/gitfourchette/forms/prefsdialog.py b/gitfourchette/forms/prefsdialog.py index d65b6f65..fb4a9553 100644 --- a/gitfourchette/forms/prefsdialog.py +++ b/gitfourchette/forms/prefsdialog.py @@ -303,6 +303,8 @@ def makeControlWidget(self, key: str, value, caption: str) -> QWidget: return self.boundedIntControl(key, value, 1, 32) elif key == "tabSpaces": return self.boundedIntControl(key, value, 1, 16) + elif key == "autoFetchMinutes": + return self.boundedIntControl(key, value, 1, 9999) elif key == "syntaxHighlighting": return self.syntaxHighlightingControl(key, value) elif key == "colorblind": diff --git a/gitfourchette/repowidget.py b/gitfourchette/repowidget.py index e9f94dc7..f9f55c6c 100644 --- a/gitfourchette/repowidget.py +++ b/gitfourchette/repowidget.py @@ -6,6 +6,7 @@ import logging import os +import time from contextlib import suppress from gitfourchette import settings @@ -101,6 +102,7 @@ def __init__(self, repoModel: RepoModel, taskRunner: RepoTaskRunner, parent: QWi self.pendingLocator = NavLocator() self.pendingEffects = TaskEffects.Nothing self.pendingStatusMessage = "" + self.lastAutoFetchTime = time.time() self.busyCursorDelayer = QTimer(self) self.busyCursorDelayer.setSingleShot(True) @@ -243,6 +245,12 @@ def __init__(self, repoModel: RepoModel, taskRunner: RepoTaskRunner, parent: QWi self.refreshWindowTitle() self.refreshBanner() + # Every second, check if we should auto-fetch. + self.autoFetchTimer = QTimer(self) + self.autoFetchTimer.timeout.connect(self.onAutoFetchTimerTimeout) + self.autoFetchTimer.setInterval(1000) + self.autoFetchTimer.start() + def replaceWithStub( self, locator: NavLocator = NavLocator.Empty, @@ -777,6 +785,19 @@ def refreshPrefs(self, prefDiff: list[str]): # Reflect any change in titlebar prefs self.refreshWindowTitle() + def onAutoFetchTimerTimeout(self): + if not settings.prefs.autoFetch or not self.isVisible() or self.taskRunner.isBusy(): + return + + # Check if it's time to auto-fetch. + now = time.time() + interval = max(1, settings.prefs.autoFetchMinutes) * 60 + if now - self.lastAutoFetchTime > interval: + from gitfourchette.tasks.nettasks import AutoFetchRemotes + AutoFetchRemotes.invoke(self) + self.lastAutoFetchTime = now + + # ------------------------------------------------------------------------- def processInternalLink(self, url: QUrl | str): diff --git a/gitfourchette/settings.py b/gitfourchette/settings.py index 64f8891c..7774279a 100644 --- a/gitfourchette/settings.py +++ b/gitfourchette/settings.py @@ -141,6 +141,8 @@ class Prefs(PrefsFile): maxRecentRepos : int = 20 shortHashChars : int = 7 autoRefresh : bool = True + autoFetch : bool = False + autoFetchMinutes : int = 5 middleClickToStage : bool = False flattenLanes : bool = True animations : bool = True diff --git a/gitfourchette/tasks/nettasks.py b/gitfourchette/tasks/nettasks.py index 5a10dd5e..13205b1d 100644 --- a/gitfourchette/tasks/nettasks.py +++ b/gitfourchette/tasks/nettasks.py @@ -192,6 +192,14 @@ def flow(self, singleRemoteName: str = ""): *argsIf(not singleRemoteName, "--all")) +class AutoFetchRemotes(FetchRemotes): + def isFreelyInterruptible(self) -> bool: + return True + + def broadcastProcesses(self) -> bool: + return False + + class FetchRemoteBranch(RepoTask): def flow(self, remoteBranchName: str = "", debrief: bool = True): shorthand = remoteBranchName diff --git a/gitfourchette/tasks/repotask.py b/gitfourchette/tasks/repotask.py index d3289003..0b167a3e 100644 --- a/gitfourchette/tasks/repotask.py +++ b/gitfourchette/tasks/repotask.py @@ -291,6 +291,15 @@ def broadcastProcesses(self) -> bool: """ return True + def terminateCurrentProcess(self): + """ + Terminate the current process associated with this task, if any. + This sends SIGTERM to the process, allowing it to clean up gracefully. + """ + if self.currentProcess and self.currentProcess.state() != QProcess.ProcessState.NotRunning: + logger.info(f"Terminating process {self.currentProcess.program()} (PID {self.currentProcess.processId()})") + self.currentProcess.terminate() + def _isRunningOnAppThread(self): return onAppThread() and self._runningOnUiThread @@ -846,6 +855,7 @@ def isBusy(self) -> bool: def killCurrentTask(self): """ Interrupt current task next time it yields a FlowControlToken. + Also terminate any running process associated with the current task. The task will not die immediately. Use joinKilledTask() after killing the task to block the current thread until the task runner is empty. @@ -854,6 +864,7 @@ def killCurrentTask(self): """ if self._currentTask: self._interruptCurrentTask = True + self._currentTask.terminateCurrentProcess() def joinKilledTask(self): """ diff --git a/gitfourchette/trtables.py b/gitfourchette/trtables.py index 3077288d..e63f1cab 100644 --- a/gitfourchette/trtables.py +++ b/gitfourchette/trtables.py @@ -478,6 +478,8 @@ def _init_prefKeys(): _("If you turn this off, you will need to hit {key} to " "perform this refresh manually.", key="F5"), "" + _("We strongly recommend to keep this setting enabled.") + ""), + "autoFetch": _("Auto-fetch remotes periodically"), + "autoFetchMinutes": _("Auto-fetch remotes every # minutes"), "animations": _("Animation effects in sidebar"), "smoothScroll": _("Smooth scrolling (where applicable)"), "forceQtApi": _("Preferred Qt binding"), diff --git a/test/test_tasks_net.py b/test/test_tasks_net.py index 171e10b1..f8e6aa77 100644 --- a/test/test_tasks_net.py +++ b/test/test_tasks_net.py @@ -19,6 +19,7 @@ from gitfourchette.forms.clonedialog import CloneDialog from gitfourchette.forms.deletetagdialog import DeleteTagDialog +from gitfourchette.forms.newbranchdialog import NewBranchDialog from gitfourchette.forms.newtagdialog import NewTagDialog from gitfourchette.forms.pushdialog import PushDialog from gitfourchette.forms.remotedialog import RemoteDialog @@ -26,6 +27,7 @@ from gitfourchette.mainwindow import NoRepoWidgetError from gitfourchette.nav import NavLocator from gitfourchette.sidebar.sidebarmodel import SidebarItem +from gitfourchette.tasks.nettasks import AutoFetchRemotes from . import reposcenario from .util import * @@ -890,3 +892,109 @@ def testAbortPullInProgress(tempDir, mainWindow, taskThread): rw.refreshRepo() waitUntilTrue(lambda: not rw.taskRunner.isBusy()) assert rw.repo.branches.remote["localfs/master"].target == oldHead + + +@pytest.mark.parametrize("enabled", [True, False]) +def testAutoFetch(tempDir, mainWindow, enabled): + """Test that auto-fetch works when enabled and conditions are met.""" + wd = unpackRepo(tempDir) + barePath = makeBareCopy(wd, addAsRemote="localfs", preFetch=True) + + mainWindow.onAcceptPrefsDialog({"autoFetch": enabled, "autoFetchMinutes": 1}) + + with RepoContext(barePath) as bareRepo: + assert bareRepo.is_bare + bareRepo.create_branch_on_head("new-remote-branch") + bareRepo.delete_local_branch("no-parent") + + rw = mainWindow.openRepo(wd) + + assert {"localfs/master", "localfs/no-parent"} == { + x for x in rw.repo.branches.remote if x.startswith("localfs/") and x != "localfs/HEAD"} + + # Manually trigger the auto-fetch timer timeout to simulate the timer firing + rw.lastAutoFetchTime = 0 + rw.onAutoFetchTimerTimeout() + + waitUntilTrue(lambda: not rw.taskRunner.isBusy()) + + branches = {x for x in rw.repo.branches.remote if x.startswith("localfs/") and x != "localfs/HEAD"} + if enabled: + assert branches == {"localfs/master", "localfs/new-remote-branch"} + else: + assert branches == {"localfs/master", "localfs/no-parent"} + + +def testOngoingAutoFetchDoesntBlockOtherTasks(tempDir, mainWindow, taskThread): + from gitfourchette import settings + gitCmd = settings.prefs.gitPath + delayGitCmd = shlex.join(["python3", getTestDataPath("delay-cmd.py"), "--", settings.prefs.gitPath]) + + # Enable auto-fetch and make sure it'll keep RepoTaskRunner busy for a few seconds + mainWindow.onAcceptPrefsDialog({ + "autoFetch": True, + "autoFetchMinutes": 1, + "gitPath": delayGitCmd, + }) + + wd = unpackRepo(tempDir) + barePath = makeBareCopy(wd, addAsRemote="localfs", preFetch=True) + with RepoContext(barePath) as bareRepo: + bareRepo.create_branch_on_head("new-remote-branch") + + # Open the repo and wait for it to settle + mainWindow.openRepo(wd) + rw = waitForRepoWidget(mainWindow) + + # Manually trigger the auto-fetch timer timeout to simulate the timer firing + rw.lastAutoFetchTime = 0 + rw.onAutoFetchTimerTimeout() + + # Make sure we're auto-fetching right now + assert isinstance(rw.taskRunner.currentTask, AutoFetchRemotes) + + # Don't delay git for the next task + mainWindow.onAcceptPrefsDialog({"gitPath": gitCmd}) + assert isinstance(rw.taskRunner.currentTask, AutoFetchRemotes) # just making sure a future version of onAcceptPrefsDialog doesn't kill the task... + + # Perform a task - any task! - while auto-fetching is in progress. + # It shouldn't be blocked by an ongoing auto-fetch. + triggerMenuAction(mainWindow.menuBar(), "repo/new local branch") + newBranchDialog = waitForQDialog(rw, "new branch", t=NewBranchDialog) + newBranchDialog.ui.nameEdit.setText("not-blocked-by-auto-fetch") + newBranchDialog.accept() + waitUntilTrue(lambda: not rw.taskRunner.isBusy()) + assert "not-blocked-by-auto-fetch" in rw.repo.branches.local + assert "localfs/new-remote-branch" not in rw.repo.branches.remote + + +def testTaskTerminationTerminatesProcess(tempDir, mainWindow, taskThread): + """Test that terminating a task also terminates its associated process.""" + from gitfourchette import settings + delayCmd = ["python3", getTestDataPath("delay-cmd.py"), "--delay", "0.5", "--", settings.prefs.gitPath] + mainWindow.onAcceptPrefsDialog({"gitPath": shlex.join(delayCmd)}) + + wd = unpackRepo(tempDir) + barePath = makeBareCopy(wd, addAsRemote="localfs", preFetch=True) + with RepoContext(barePath) as bareRepo: + bareRepo.create_branch_on_head("new-remote-branch") + mainWindow.openRepo(wd) + rw = waitForRepoWidget(mainWindow) + + # Start a fetch task that will take 3 seconds due to the delay command + from gitfourchette.tasks.nettasks import FetchRemotes + FetchRemotes.invoke(rw) + + waitUntilTrue(lambda: rw.taskRunner.isBusy()) + QTest.qWait(100) + assert isinstance(rw.taskRunner.currentTask, FetchRemotes), "task should still be running" + process = rw.taskRunner.currentTask.currentProcess + assert process.state() != QProcess.ProcessState.NotRunning, "process should be running" + + rw.taskRunner.killCurrentTask() + waitUntilTrue(lambda: not rw.taskRunner.isBusy()) + assert rw.taskRunner.currentTask is None, "task should be terminated" + QTest.qWait(1000) + + # Check that the branch was not fetched + assert "localfs/new-remote-branch" not in rw.repo.branches.remote