Files
converge/src/Converge.hs
T
jbrechtel 266dd117c6 feat: add thread-safe logger and filter .git fs events
- Introduce Logger newtype (MVar-backed) to serialize stderr output
  across concurrent repo watchers, preventing interleaved log lines
- Thread Logger through runConverge → watchRepo → syncRepo → gitCommitAll
- Switch watchDir → watchTree with isGitPath filter to ignore events
  inside .git directories, reducing noise and potential feedback loops
- Extract logEvent as a standalone function
2026-05-11 20:53:23 -04:00

311 lines
11 KiB
Haskell

module Converge (
-- * Core types
Options (..),
RepoConfig (..),
defaultOptions,
defaultRepoConfig,
-- * Config file
Config (..),
loadConfig,
configToOptions,
defaultConfigPath,
configFileExists,
-- * Running
runConverge,
-- * Git operations
gitCommitAll,
gitPull,
hasConflicts,
-- * Notifications
notifyConflict,
)
where
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.Async (mapConcurrently_)
import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, takeMVar, tryPutMVar, tryTakeMVar, withMVar)
import Control.Exception (bracket)
import Control.Monad (forever, unless, void)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Yaml (FromJSON (..), decodeFileThrow, withObject, (.:), (.:?))
import System.Directory (XdgDirectory (XdgConfig), doesFileExist, getCurrentDirectory, getXdgDirectory)
import System.Exit (ExitCode (..))
import System.FSNotify
import System.FilePath (isAbsolute, splitDirectories, (</>))
import System.IO (hPutStrLn, stderr)
import System.Process (readProcessWithExitCode, spawnProcess)
-- | Configuration for a single repository to watch.
data RepoConfig = RepoConfig
{ rcPath :: !FilePath
-- ^ Path to the git repository.
, rcRemote :: !Text
-- ^ Remote name to pull from.
, rcBranch :: !Text
-- ^ Branch to pull.
}
deriving (Show, Eq)
-- | Top-level options for converge.
data Options = Options
{ optRepos :: ![RepoConfig]
-- ^ Repositories to watch.
, optDebounceMs :: !Int
-- ^ Debounce window in milliseconds.
, optNotifyCmd :: !Text
-- ^ Command used for desktop notifications.
}
deriving (Show, Eq)
-- | Sensible defaults for a single repo (cwd, origin, main).
defaultRepoConfig :: RepoConfig
defaultRepoConfig =
RepoConfig
{ rcPath = "."
, rcRemote = "origin"
, rcBranch = "main"
}
-- | Sensible defaults for options.
defaultOptions :: Options
defaultOptions =
Options
{ optRepos = [defaultRepoConfig]
, optDebounceMs = 5000
, optNotifyCmd = "notify-send"
}
{- | Logger that serializes output to stderr so concurrent repo watchers
don't interleave their log lines.
-}
newtype Logger = Logger (MVar ())
-- | Create a new thread-safe logger.
newLogger :: IO Logger
newLogger = Logger <$> newMVar ()
----------------------------------------------------------------------
-- Config file (YAML)
----------------------------------------------------------------------
-- | Shape of the optional YAML config file.
data Config = Config
{ cfgRepos :: ![RepoConfig]
, cfgDebounceMs :: !(Maybe Int)
}
deriving (Show, Eq)
instance FromJSON Config where
parseJSON = withObject "Config" $ \o ->
Config
<$> o .: "repos"
<*> o .:? "debounce_ms"
instance FromJSON RepoConfig where
parseJSON = withObject "RepoConfig" $ \o ->
RepoConfig
<$> o .: "path"
<*> (fromMaybe "origin" <$> o .:? "remote")
<*> (fromMaybe "main" <$> o .:? "branch")
-- | Load a YAML config file.
loadConfig :: FilePath -> IO Config
loadConfig = decodeFileThrow
-- | Convert a Config to Options (filling in defaults).
configToOptions :: Config -> Options
configToOptions cfg =
defaultOptions
{ optRepos = cfgRepos cfg
, optDebounceMs = fromMaybe 5000 (cfgDebounceMs cfg)
}
-- | The default XDG config file path: @$XDG_CONFIG_HOME/converge/config.yaml@.
defaultConfigPath :: IO FilePath
defaultConfigPath = (</> "converge/config.yaml") <$> getXdgDirectory XdgConfig ""
-- | Check whether the default config file exists.
configFileExists :: IO Bool
configFileExists = defaultConfigPath >>= doesFileExist
----------------------------------------------------------------------
-- Logging
----------------------------------------------------------------------
-- | Log a timestamped message to stderr.
logInfo :: Logger -> Text -> IO ()
logInfo (Logger lock) msg = withMVar lock $ \() -> do
now <- getCurrentTime
let ts = T.pack $ formatTime defaultTimeLocale "%H:%M:%S" now
hPutStrLn stderr $ "[" <> T.unpack ts <> "] " <> T.unpack msg
-- | Log a message tagged with a repo path.
logRepo :: Logger -> RepoConfig -> Text -> IO ()
logRepo logger repo msg = logInfo logger ("[" <> T.pack (rcPath repo) <> "] " <> msg)
----------------------------------------------------------------------
-- Running
----------------------------------------------------------------------
-- | Watch all configured repositories concurrently.
runConverge :: Options -> IO ()
runConverge opts = do
logger <- newLogger
mapConcurrently_ (watchRepo opts logger) (optRepos opts)
{- | Watch a single repository for changes.
Uses an 'MVar' to coalesce rapid events: the first event triggers a
debounce timer; subsequent events during the debounce window are
accumulated and drained, so only one sync runs per quiet period.
-}
watchRepo :: Options -> Logger -> RepoConfig -> IO ()
watchRepo opts logger repo = do
absPath <- resolvePath (rcPath repo)
let debounceUs = optDebounceMs opts * 1000
logRepo logger repo ("watching " <> T.pack absPath <> " (debounce: " <> T.pack (show (optDebounceMs opts)) <> "ms)")
pending <- newEmptyMVar
-- Background sync loop: waits for an event signal, debounces, then syncs
_ <- forkIO $ forever $ do
takeMVar pending
threadDelay debounceUs
-- Drain any events that arrived during the debounce window
void $ tryTakeMVar pending
syncRepo opts logger repo
bracket
(startManagerConf defaultConfig)
stopManager
$ \mgr -> do
_ <- watchTree mgr absPath (\e -> not (isGitPath (eventPath e))) $ \event -> do
logEvent logger repo event
void $ tryPutMVar pending ()
forever $ threadDelay maxBound
where
resolvePath p
| isAbsolute p = pure p
| otherwise = (</> p) <$> getCurrentDirectory
-- | Log a filesystem event.
logEvent :: Logger -> RepoConfig -> Event -> IO ()
logEvent logger repo event =
logRepo logger repo $
T.pack (eventPath event)
<> " "
<> eventLabel event
eventLabel :: Event -> Text
eventLabel event = case event of
Added{} -> "added"
Modified{} -> "modified"
ModifiedAttributes{} -> "modified (attrs)"
Removed{} -> "removed"
WatchedDirectoryRemoved{} -> "dir removed"
CloseWrite{} -> "write closed"
Unknown{} -> "unknown"
-- | Check whether a file path lies inside a @.git@ directory.
isGitPath :: FilePath -> Bool
isGitPath = elem ".git" . splitDirectories
-- | Run a full sync cycle on a single repo: commit + pull + conflict check.
syncRepo :: Options -> Logger -> RepoConfig -> IO ()
syncRepo opts logger repo = do
logRepo logger repo "sync cycle starting"
-- Check what's changed
(_, statusOut, _) <- runGitIn (rcPath repo) ["status", "--porcelain"]
let changed = filter (not . T.null) (T.lines statusOut)
if null changed
then logRepo logger repo "no changes to commit"
else do
let count = length changed
let preview = T.unlines (take 15 changed)
logRepo logger repo ("staging " <> T.pack (show count) <> " file(s):\n" <> preview)
(commitCode, commitOut, commitErr) <- gitCommitAll logger (rcPath repo)
case commitCode of
ExitSuccess -> do
(_, hashOut, _) <- runGitIn (rcPath repo) ["log", "-1", "--format=%h %s"]
logRepo logger repo ("committed " <> T.strip hashOut)
_
| "nothing to commit" `T.isInfixOf` commitOut
|| "nothing to commit" `T.isInfixOf` commitErr ->
logRepo logger repo "nothing to commit (working tree clean)"
| otherwise ->
logRepo logger repo ("commit FAILED (exit " <> T.pack (show commitCode) <> "): " <> T.strip (commitOut <> commitErr))
-- Pull
logRepo logger repo ("pulling " <> rcRemote repo <> "/" <> rcBranch repo)
(pullCode, pullOut, pullErr) <- gitPull repo
case pullCode of
ExitSuccess -> do
let trimmed = T.strip pullOut
if T.null trimmed
then logRepo logger repo "pull: already up to date"
else logRepo logger repo ("pull succeeded:\n" <> trimmed)
_ ->
logRepo logger repo ("pull FAILED (exit " <> T.pack (show pullCode) <> "): " <> T.strip (pullOut <> pullErr))
-- Conflict check
conflicted <- hasConflicts (rcPath repo)
if conflicted
then do
logRepo logger repo "*** MERGE CONFLICT detected! ***"
notifyConflict opts repo
else logRepo logger repo "sync complete, no conflicts"
----------------------------------------------------------------------
-- Git operations
----------------------------------------------------------------------
-- | Stage all changes and commit with an auto-generated message.
gitCommitAll :: Logger -> FilePath -> IO (ExitCode, Text, Text)
gitCommitAll logger repoPath = do
(_, _, stageErr) <- runGitIn repoPath ["add", "-A"]
unless (T.null stageErr) $
logInfo logger ("git add stderr: " <> stageErr)
runGitIn repoPath ["commit", "-m", "converge: auto-commit"]
-- | Pull from the configured remote and branch.
gitPull :: RepoConfig -> IO (ExitCode, Text, Text)
gitPull repo =
runGitIn
(rcPath repo)
["pull", "--no-edit", T.unpack (rcRemote repo), T.unpack (rcBranch repo)]
-- | Check if the repository currently has merge conflicts.
hasConflicts :: FilePath -> IO Bool
hasConflicts repoPath = do
(_, diffOut, _) <- runGitIn repoPath ["diff", "--check"]
pure $ any (`T.isInfixOf` diffOut) ["<<<<<<<", "=======", ">>>>>>>"]
----------------------------------------------------------------------
-- Notifications
----------------------------------------------------------------------
-- | Send a desktop notification about a merge conflict.
notifyConflict :: Options -> RepoConfig -> IO ()
notifyConflict opts repo = do
let cmd = T.unpack (optNotifyCmd opts)
title = "Converge: Merge Conflict"
body =
"A merge conflict occurred in "
<> T.pack (rcPath repo)
<> ". Manual resolution required."
_ <- spawnProcess cmd ["--urgency=critical", title, T.unpack body]
pure ()
----------------------------------------------------------------------
-- Internal helpers
----------------------------------------------------------------------
-- | Run a git command in the given repository directory.
runGitIn :: FilePath -> [String] -> IO (ExitCode, Text, Text)
runGitIn repoPath args = do
(code, out, err) <- readProcessWithExitCode "git" ("-C" : repoPath : args) ""
pure (code, T.pack out, T.pack err)