Skip to content
2 changes: 2 additions & 0 deletions gitfourchette/forms/prefsdialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
21 changes: 21 additions & 0 deletions gitfourchette/repowidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import logging
import os
import time
from contextlib import suppress

from gitfourchette import settings
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions gitfourchette/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions gitfourchette/tasks/nettasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions gitfourchette/tasks/repotask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -854,6 +864,7 @@ def killCurrentTask(self):
"""
if self._currentTask:
self._interruptCurrentTask = True
self._currentTask.terminateCurrentProcess()

def joinKilledTask(self):
"""
Expand Down
2 changes: 2 additions & 0 deletions gitfourchette/trtables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
"<b>" + _("We strongly recommend to keep this setting enabled.") + "</b>"),
"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"),
Expand Down
108 changes: 108 additions & 0 deletions test/test_tasks_net.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@

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
from gitfourchette.gitdriver import GitDriver
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 *

Expand Down Expand Up @@ -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