From 6d637ad152483e6cf86cf347b2bc8a5a196b42d8 Mon Sep 17 00:00:00 2001 From: Lynn Date: Tue, 16 Sep 2025 16:12:07 +0200 Subject: [PATCH 01/14] Auto-fetch periodically --- gitfourchette/repowidget.py | 24 ++++++++++++++++++++++++ gitfourchette/settings.py | 2 ++ gitfourchette/tasks/nettasks.py | 6 +++++- gitfourchette/trtables.py | 2 ++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/gitfourchette/repowidget.py b/gitfourchette/repowidget.py index e9f94dc7..82c51ab0 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, @@ -776,6 +784,22 @@ 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() + if now - self.lastAutoFetchTime > settings.prefs.autoFetchMinutes * 60: + from gitfourchette.tasks.nettasks import FetchRemotes + + # Disconnect processStarted to avoid showing the process dialog. + self.taskRunner.processStarted.disconnect(self.processDialog.connectProcess) + FetchRemotes.invoke(self, auto=True) + self.taskRunner.processStarted.connect(self.processDialog.connectProcess) + self.lastAutoFetchTime = now + # ------------------------------------------------------------------------- diff --git a/gitfourchette/settings.py b/gitfourchette/settings.py index 64f8891c..ebcd02c3 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 = True + 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..91fe1757 100644 --- a/gitfourchette/tasks/nettasks.py +++ b/gitfourchette/tasks/nettasks.py @@ -161,7 +161,11 @@ def flow(self, remoteBranchShorthand: str): class FetchRemotes(RepoTask): - def flow(self, singleRemoteName: str = ""): + def isFreelyInterruptible(self) -> bool: + return self.auto + + def flow(self, singleRemoteName: str = "", auto: bool = False): + self.auto = auto remotes: list[Remote] = list(self.repo.remotes) if len(remotes) == 0: 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"), From 4a00f234323efa9d539881ad28d7242931ae8930 Mon Sep 17 00:00:00 2001 From: Lynn Date: Tue, 16 Sep 2025 17:58:41 +0200 Subject: [PATCH 02/14] Remove whitespace from blank line --- gitfourchette/repowidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitfourchette/repowidget.py b/gitfourchette/repowidget.py index 82c51ab0..2afb97ff 100644 --- a/gitfourchette/repowidget.py +++ b/gitfourchette/repowidget.py @@ -784,7 +784,7 @@ 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 From 55d4761375b5f77c411108faedd02d1be6d5a12d Mon Sep 17 00:00:00 2001 From: Lynn Date: Sat, 20 Sep 2025 20:25:31 +0200 Subject: [PATCH 03/14] Add a test --- test/test_tasks_net.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/test_tasks_net.py b/test/test_tasks_net.py index 171e10b1..62f057bd 100644 --- a/test/test_tasks_net.py +++ b/test/test_tasks_net.py @@ -14,6 +14,7 @@ import os.path import re import shlex +import time import pytest @@ -890,3 +891,34 @@ 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"} From 8467272c1b41653c99fe4ccd200bf7de6b344592 Mon Sep 17 00:00:00 2001 From: Lynn Date: Sat, 20 Sep 2025 20:26:02 +0200 Subject: [PATCH 04/14] False by default --- gitfourchette/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitfourchette/settings.py b/gitfourchette/settings.py index ebcd02c3..7774279a 100644 --- a/gitfourchette/settings.py +++ b/gitfourchette/settings.py @@ -141,7 +141,7 @@ class Prefs(PrefsFile): maxRecentRepos : int = 20 shortHashChars : int = 7 autoRefresh : bool = True - autoFetch : bool = True + autoFetch : bool = False autoFetchMinutes : int = 5 middleClickToStage : bool = False flattenLanes : bool = True From 80241463144e89e98c9749848e97c27843c39c75 Mon Sep 17 00:00:00 2001 From: Lynn Date: Sat, 20 Sep 2025 20:26:51 +0200 Subject: [PATCH 05/14] Floor the delay at 1 minute --- gitfourchette/repowidget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gitfourchette/repowidget.py b/gitfourchette/repowidget.py index 2afb97ff..d952eace 100644 --- a/gitfourchette/repowidget.py +++ b/gitfourchette/repowidget.py @@ -791,7 +791,8 @@ def onAutoFetchTimerTimeout(self): # Check if it's time to auto-fetch. now = time.time() - if now - self.lastAutoFetchTime > settings.prefs.autoFetchMinutes * 60: + interval = min(1, settings.prefs.autoFetchMinutes) * 60 + if now - self.lastAutoFetchTime > interval: from gitfourchette.tasks.nettasks import FetchRemotes # Disconnect processStarted to avoid showing the process dialog. From 765c87d6efa80a0980ca5c2b21054b1b61e0b2b4 Mon Sep 17 00:00:00 2001 From: Lynn Date: Sat, 20 Sep 2025 20:28:30 +0200 Subject: [PATCH 06/14] Use boundedIntControl --- gitfourchette/forms/prefsdialog.py | 2 ++ 1 file changed, 2 insertions(+) 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": From 18fd9fd2f6856e3d4ffc9b0cc761e5b0a7dd4d19 Mon Sep 17 00:00:00 2001 From: Lynn Date: Sat, 20 Sep 2025 20:34:51 +0200 Subject: [PATCH 07/14] Create AutoFetchRemotes subclass --- gitfourchette/repowidget.py | 8 ++------ gitfourchette/tasks/nettasks.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/gitfourchette/repowidget.py b/gitfourchette/repowidget.py index d952eace..2ead52c8 100644 --- a/gitfourchette/repowidget.py +++ b/gitfourchette/repowidget.py @@ -793,12 +793,8 @@ def onAutoFetchTimerTimeout(self): now = time.time() interval = min(1, settings.prefs.autoFetchMinutes) * 60 if now - self.lastAutoFetchTime > interval: - from gitfourchette.tasks.nettasks import FetchRemotes - - # Disconnect processStarted to avoid showing the process dialog. - self.taskRunner.processStarted.disconnect(self.processDialog.connectProcess) - FetchRemotes.invoke(self, auto=True) - self.taskRunner.processStarted.connect(self.processDialog.connectProcess) + from gitfourchette.tasks.nettasks import AutoFetchRemotes + AutoFetchRemotes.invoke(self) self.lastAutoFetchTime = now diff --git a/gitfourchette/tasks/nettasks.py b/gitfourchette/tasks/nettasks.py index 91fe1757..13205b1d 100644 --- a/gitfourchette/tasks/nettasks.py +++ b/gitfourchette/tasks/nettasks.py @@ -161,11 +161,7 @@ def flow(self, remoteBranchShorthand: str): class FetchRemotes(RepoTask): - def isFreelyInterruptible(self) -> bool: - return self.auto - - def flow(self, singleRemoteName: str = "", auto: bool = False): - self.auto = auto + def flow(self, singleRemoteName: str = ""): remotes: list[Remote] = list(self.repo.remotes) if len(remotes) == 0: @@ -196,6 +192,14 @@ def flow(self, singleRemoteName: str = "", auto: bool = False): *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 From 3bfd8b8c62176a1c6d36bb74bd7ce5c1e5e3f58c Mon Sep 17 00:00:00 2001 From: Lynn Date: Sat, 20 Sep 2025 20:42:11 +0200 Subject: [PATCH 08/14] Terminate a RepoTask's current process --- gitfourchette/tasks/repotask.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gitfourchette/tasks/repotask.py b/gitfourchette/tasks/repotask.py index d3289003..63be0908 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.Running: + 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): """ From 434ed9597402a917cb94fb5633d1d6fa4c23adeb Mon Sep 17 00:00:00 2001 From: Lynn Date: Sun, 21 Sep 2025 17:45:03 +0200 Subject: [PATCH 09/14] Update gitfourchette/repowidget.py Co-authored-by: Iliyas Jorio --- gitfourchette/repowidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitfourchette/repowidget.py b/gitfourchette/repowidget.py index 2ead52c8..f9f55c6c 100644 --- a/gitfourchette/repowidget.py +++ b/gitfourchette/repowidget.py @@ -791,7 +791,7 @@ def onAutoFetchTimerTimeout(self): # Check if it's time to auto-fetch. now = time.time() - interval = min(1, settings.prefs.autoFetchMinutes) * 60 + interval = max(1, settings.prefs.autoFetchMinutes) * 60 if now - self.lastAutoFetchTime > interval: from gitfourchette.tasks.nettasks import AutoFetchRemotes AutoFetchRemotes.invoke(self) From fc01913c704d6daf791c39a702988ab1d5f08e88 Mon Sep 17 00:00:00 2001 From: Lynn Date: Sun, 21 Sep 2025 17:45:13 +0200 Subject: [PATCH 10/14] Update test/test_tasks_net.py Co-authored-by: Iliyas Jorio --- test/test_tasks_net.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_tasks_net.py b/test/test_tasks_net.py index 62f057bd..3962abf9 100644 --- a/test/test_tasks_net.py +++ b/test/test_tasks_net.py @@ -14,7 +14,6 @@ import os.path import re import shlex -import time import pytest From 4e40d018ab1937109249677a82e79228838e51d6 Mon Sep 17 00:00:00 2001 From: Lynn Date: Sun, 21 Sep 2025 17:45:23 +0200 Subject: [PATCH 11/14] Update gitfourchette/tasks/repotask.py Co-authored-by: Iliyas Jorio --- gitfourchette/tasks/repotask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitfourchette/tasks/repotask.py b/gitfourchette/tasks/repotask.py index 63be0908..0b167a3e 100644 --- a/gitfourchette/tasks/repotask.py +++ b/gitfourchette/tasks/repotask.py @@ -296,7 +296,7 @@ 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.Running: + 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() From 189603952f44f49b0aed1b1c04bd6cb1ec622ffd Mon Sep 17 00:00:00 2001 From: Lynn Date: Sun, 21 Sep 2025 17:51:32 +0200 Subject: [PATCH 12/14] Add testOngoingAutoFetchDoesntBlockOtherTasks --- test/test_tasks_net.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/test_tasks_net.py b/test/test_tasks_net.py index 3962abf9..824b5320 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 * @@ -921,3 +923,46 @@ def testAutoFetch(tempDir, mainWindow, 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 From ba74f3f7dab0b3daa3bf626d8b6eb7995ff24d5b Mon Sep 17 00:00:00 2001 From: Lynn Date: Sun, 21 Sep 2025 18:15:21 +0200 Subject: [PATCH 13/14] Test that terminating a task also terminates its associated process --- test/test_tasks_net.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/test_tasks_net.py b/test/test_tasks_net.py index 824b5320..410994b8 100644 --- a/test/test_tasks_net.py +++ b/test/test_tasks_net.py @@ -966,3 +966,35 @@ def testOngoingAutoFetchDoesntBlockOtherTasks(tempDir, mainWindow, taskThread): 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 + a = 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 From 2c9137c1f6bc020404af00ebdb2cbd7803c94d90 Mon Sep 17 00:00:00 2001 From: Lynn Date: Mon, 22 Sep 2025 21:37:24 +0200 Subject: [PATCH 14/14] Remove unnecessary assignment --- test/test_tasks_net.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_tasks_net.py b/test/test_tasks_net.py index 410994b8..f8e6aa77 100644 --- a/test/test_tasks_net.py +++ b/test/test_tasks_net.py @@ -983,7 +983,7 @@ def testTaskTerminationTerminatesProcess(tempDir, mainWindow, taskThread): # Start a fetch task that will take 3 seconds due to the delay command from gitfourchette.tasks.nettasks import FetchRemotes - a = FetchRemotes.invoke(rw) + FetchRemotes.invoke(rw) waitUntilTrue(lambda: rw.taskRunner.isBusy()) QTest.qWait(100)