Add configurable log levels and log file support
Build / build (push) Failing after 7s

- New LogLevel type: Debug, Info, Warn, Error with Ord instance
- Logger holds configured level and output handle (stderr or file)
- --log-level and --log-file CLI flags
- log_level and log_file YAML config fields
- Log messages assigned appropriate severity levels
- Test logger updated to new Logger structure
- All 17 tests passing
This commit is contained in:
2026-05-14 10:13:36 -04:00
parent d22ea39cde
commit f66dfcbccb
5 changed files with 152 additions and 45 deletions
+3
View File
@@ -12,3 +12,6 @@
### a revert is in progess ### a revert is in progess
### a cherry-pick is in progess ### a cherry-pick is in progess
### a bisect is in progess ### a bisect is in progess
## Supports configurable log levels (debug, info, warn, error)
## Supports logging to a file instead of stderr via --log-file or log_file config
+49 -15
View File
@@ -3,6 +3,7 @@ module Main (main) where
import Converge import Converge
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T
import Options.Applicative import Options.Applicative
import System.IO (hPutStrLn, stderr) import System.IO (hPutStrLn, stderr)
@@ -17,6 +18,10 @@ data CliArgs = CliArgs
-- ^ Branch name (used only in single-repo mode). -- ^ Branch name (used only in single-repo mode).
, cliDebounce :: !(Maybe Int) , cliDebounce :: !(Maybe Int)
-- ^ Debounce override for both modes. -- ^ Debounce override for both modes.
, cliLogLevel :: !(Maybe LogLevel)
-- ^ Log level override.
, cliLogFile :: !(Maybe FilePath)
-- ^ Log file path.
} }
cliParser :: Parser CliArgs cliParser :: Parser CliArgs
@@ -62,6 +67,26 @@ cliParser =
<> help "Debounce window in milliseconds (overrides config file)" <> help "Debounce window in milliseconds (overrides config file)"
) )
) )
<*> optional
( option
(maybeReader (\s -> case T.toLower (T.pack s) of
"debug" -> Just Debug
"info" -> Just Info
"warn" -> Just Warn
"error" -> Just Error
_ -> Nothing))
( long "log-level"
<> metavar "LEVEL"
<> help "Minimum log level: debug, info, warn, or error (default: info)"
)
)
<*> optional
( strOption
( long "log-file"
<> metavar "FILE"
<> help "Write logs to a file instead of stderr"
)
)
parserInfo :: ParserInfo CliArgs parserInfo :: ParserInfo CliArgs
parserInfo = parserInfo =
@@ -79,7 +104,7 @@ main = do
(options, configSource) <- case cliConfig cli of (options, configSource) <- case cliConfig cli of
Just cfgPath -> do Just cfgPath -> do
cfg <- loadConfig cfgPath cfg <- loadConfig cfgPath
let opts = applyDebounce mDebounceOverride (configToOptions cfg) let opts = applyOverrides mDebounceOverride (cliLogLevel cli) (cliLogFile cli) (configToOptions cfg)
pure (opts, Just cfgPath) pure (opts, Just cfgPath)
Nothing -> do Nothing -> do
exists <- configFileExists exists <- configFileExists
@@ -87,20 +112,21 @@ main = do
then do then do
cfgPath <- defaultConfigPath cfgPath <- defaultConfigPath
cfg <- loadConfig cfgPath cfg <- loadConfig cfgPath
let opts = applyDebounce mDebounceOverride (configToOptions cfg) let opts = applyOverrides mDebounceOverride (cliLogLevel cli) (cliLogFile cli) (configToOptions cfg)
pure (opts, Just cfgPath) pure (opts, Just cfgPath)
else else
pure pure
( defaultOptions ( applyOverrides mDebounceOverride (cliLogLevel cli) (cliLogFile cli)
{ optRepos = defaultOptions
[ defaultRepoConfig { optRepos =
{ rcPath = fromMaybe "." (cliRepo cli) [ defaultRepoConfig
, rcRemote = cliRemote cli { rcPath = fromMaybe "." (cliRepo cli)
, rcBranch = cliBranch cli , rcRemote = cliRemote cli
} , rcBranch = cliBranch cli
] }
, optDebounceMs = fromMaybe 5000 mDebounceOverride ]
} , optDebounceMs = fromMaybe 5000 mDebounceOverride
}
, Nothing , Nothing
) )
case configSource of case configSource of
@@ -118,6 +144,14 @@ main = do
mapM_ (\r -> hPutStrLn stderr (" - " <> rcPath r)) (optRepos options) mapM_ (\r -> hPutStrLn stderr (" - " <> rcPath r)) (optRepos options)
runConverge options runConverge options
applyDebounce :: Maybe Int -> Options -> Options
applyDebounce (Just ms) opts = opts{optDebounceMs = ms} -- | Apply CLI overrides (debounce, log level, log file) to Options.
applyDebounce Nothing opts = opts applyOverrides :: Maybe Int -> Maybe LogLevel -> Maybe FilePath -> Options -> Options
applyOverrides mDebounce mLogLevel mLogFile opts =
opts
{ optDebounceMs = maybe (optDebounceMs opts) id mDebounce
, optLogLevel = fromMaybe (optLogLevel opts) mLogLevel
, optLogFile = case mLogFile of
Just _ -> mLogFile -- CLI --log-file overrides config
Nothing -> optLogFile opts
}
+3
View File
@@ -11,3 +11,6 @@ repos:
# branch defaults to "main" # branch defaults to "main"
# debounce_ms: 5000 # optional, defaults to 5000 # debounce_ms: 5000 # optional, defaults to 5000
# log_level: info # optional, one of: debug, info, warn, error (default: info)
# log_file: /var/log/converge.log # optional, write logs to a file instead of stderr
+93 -27
View File
@@ -2,6 +2,7 @@ module Converge (
-- * Core types -- * Core types
Options (..), Options (..),
RepoConfig (..), RepoConfig (..),
LogLevel (..),
defaultOptions, defaultOptions,
defaultRepoConfig, defaultRepoConfig,
@@ -47,12 +48,12 @@ import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Time.Clock (getCurrentTime) import Data.Time.Clock (getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Yaml (FromJSON (..), decodeFileThrow, withObject, (.:), (.:?)) import Data.Yaml (FromJSON (..), decodeFileThrow, withObject, withText, (.:), (.:?))
import System.Directory (XdgDirectory (XdgConfig), doesDirectoryExist, doesFileExist, getCurrentDirectory, getXdgDirectory) import System.Directory (XdgDirectory (XdgConfig), doesDirectoryExist, doesFileExist, getCurrentDirectory, getXdgDirectory)
import System.Exit (ExitCode (..)) import System.Exit (ExitCode (..))
import System.FSNotify import System.FSNotify
import System.FilePath (isAbsolute, splitDirectories, (</>)) import System.FilePath (isAbsolute, splitDirectories, (</>))
import System.IO (hPutStrLn, stderr) import System.IO (BufferMode (..), Handle, IOMode (..), hFlush, hPutStrLn, hSetBuffering, openFile, stderr)
import System.Process (readProcess, readProcessWithExitCode, spawnProcess) import System.Process (readProcess, readProcessWithExitCode, spawnProcess)
-- | Configuration for a single repository to watch. -- | Configuration for a single repository to watch.
@@ -66,6 +67,22 @@ data RepoConfig = RepoConfig
} }
deriving (Show, Eq) deriving (Show, Eq)
-- | Log level controlling verbosity of output.
data LogLevel
= Debug
| Info
| Warn
| Error
deriving (Show, Eq, Ord, Bounded, Enum)
instance FromJSON LogLevel where
parseJSON = withText "LogLevel" $ \t -> case T.toLower t of
"debug" -> pure Debug
"info" -> pure Info
"warn" -> pure Warn
"error" -> pure Error
other -> fail $ "Unknown log level: " <> T.unpack other <> ". Expected: debug, info, warn, or error"
-- | Top-level options for converge. -- | Top-level options for converge.
data Options = Options data Options = Options
{ optRepos :: ![RepoConfig] { optRepos :: ![RepoConfig]
@@ -74,6 +91,10 @@ data Options = Options
-- ^ Debounce window in milliseconds. -- ^ Debounce window in milliseconds.
, optNotifyCmd :: !Text , optNotifyCmd :: !Text
-- ^ Command used for desktop notifications. -- ^ Command used for desktop notifications.
, optLogLevel :: !LogLevel
-- ^ Minimum log level to emit.
, optLogFile :: !(Maybe FilePath)
-- ^ Optional log file path (logs to stderr when Nothing).
} }
deriving (Show, Eq) deriving (Show, Eq)
@@ -93,16 +114,31 @@ defaultOptions =
{ optRepos = [defaultRepoConfig] { optRepos = [defaultRepoConfig]
, optDebounceMs = 5000 , optDebounceMs = 5000
, optNotifyCmd = "notify-send" , optNotifyCmd = "notify-send"
, optLogLevel = Info
, optLogFile = Nothing
} }
{- | Logger that serializes output to stderr so concurrent repo watchers {- | Logger that serializes output so concurrent repo watchers
don't interleave their log lines. don't interleave their log lines. Writes to stderr or a file
depending on configuration.
-} -}
newtype Logger = Logger (MVar ()) data Logger = Logger
{ loggerLock :: !(MVar ())
, loggerLevel :: !LogLevel
, loggerHandle :: !Handle
}
-- | Create a new thread-safe logger. -- | Create a new thread-safe logger.
newLogger :: IO Logger newLogger :: LogLevel -> Maybe FilePath -> IO Logger
newLogger = Logger <$> newMVar () newLogger lvl mFile = do
lock <- newMVar ()
handle <- case mFile of
Nothing -> pure stderr
Just path -> do
h <- openFile path AppendMode
hSetBuffering h LineBuffering
pure h
pure $ Logger lock lvl handle
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Config file (YAML) -- Config file (YAML)
@@ -112,6 +148,8 @@ newLogger = Logger <$> newMVar ()
data Config = Config data Config = Config
{ cfgRepos :: ![RepoConfig] { cfgRepos :: ![RepoConfig]
, cfgDebounceMs :: !(Maybe Int) , cfgDebounceMs :: !(Maybe Int)
, cfgLogLevel :: !(Maybe LogLevel)
, cfgLogFile :: !(Maybe FilePath)
} }
deriving (Show, Eq) deriving (Show, Eq)
@@ -120,6 +158,8 @@ instance FromJSON Config where
Config Config
<$> o .: "repos" <$> o .: "repos"
<*> o .:? "debounce_ms" <*> o .:? "debounce_ms"
<*> o .:? "log_level"
<*> o .:? "log_file"
instance FromJSON RepoConfig where instance FromJSON RepoConfig where
parseJSON = withObject "RepoConfig" $ \o -> parseJSON = withObject "RepoConfig" $ \o ->
@@ -138,6 +178,8 @@ configToOptions cfg =
defaultOptions defaultOptions
{ optRepos = cfgRepos cfg { optRepos = cfgRepos cfg
, optDebounceMs = fromMaybe 5000 (cfgDebounceMs cfg) , optDebounceMs = fromMaybe 5000 (cfgDebounceMs cfg)
, optLogLevel = fromMaybe Info (cfgLogLevel cfg)
, optLogFile = cfgLogFile cfg
} }
-- | The default XDG config file path: @$XDG_CONFIG_HOME/converge/config.yaml@. -- | The default XDG config file path: @$XDG_CONFIG_HOME/converge/config.yaml@.
@@ -152,16 +194,37 @@ configFileExists = defaultConfigPath >>= doesFileExist
-- Logging -- Logging
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- | Log a timestamped message to stderr. -- | Log a timestamped message at a given level. Messages below the
logInfo :: Logger -> Text -> IO () -- Logger's configured minimum level are silently dropped.
logInfo (Logger lock) msg = withMVar lock $ \() -> do logMsg :: Logger -> LogLevel -> Text -> IO ()
now <- getCurrentTime logMsg (Logger lock lvl handle) level msg =
let ts = T.pack $ formatTime defaultTimeLocale "%H:%M:%S" now when (level >= lvl) $
hPutStrLn stderr $ "[" <> T.unpack ts <> "] " <> T.unpack msg withMVar lock $ \() -> do
now <- getCurrentTime
let ts = T.pack $ formatTime defaultTimeLocale "%H:%M:%S" now
let levelTag = case level of
Debug -> "DEBUG"
Info -> "INFO "
Warn -> "WARN "
Error -> "ERROR"
hPutStrLn handle $ "[" <> T.unpack ts <> "] " <> levelTag <> " " <> T.unpack msg
hFlush handle
-- | Log a message tagged with a repo path. -- | Log at Debug level.
logDebug :: Logger -> Text -> IO ()
logDebug logger msg = logMsg logger Debug msg
-- | Log at Warn level.
logWarn :: Logger -> Text -> IO ()
logWarn logger msg = logMsg logger Warn msg
-- | Log a message tagged with a repo path (at a given level).
logRepoAt :: Logger -> LogLevel -> RepoConfig -> Text -> IO ()
logRepoAt logger level repo msg = logMsg logger level ("[" <> T.pack (rcPath repo) <> "] " <> msg)
-- | Log a message tagged with a repo path at Info level.
logRepo :: Logger -> RepoConfig -> Text -> IO () logRepo :: Logger -> RepoConfig -> Text -> IO ()
logRepo logger repo msg = logInfo logger ("[" <> T.pack (rcPath repo) <> "] " <> msg) logRepo logger repo msg = logRepoAt logger Info repo msg
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Running -- Running
@@ -170,7 +233,7 @@ logRepo logger repo msg = logInfo logger ("[" <> T.pack (rcPath repo) <> "] " <>
-- | Watch all configured repositories concurrently. -- | Watch all configured repositories concurrently.
runConverge :: Options -> IO () runConverge :: Options -> IO ()
runConverge opts = do runConverge opts = do
logger <- newLogger logger <- newLogger (optLogLevel opts) (optLogFile opts)
mapConcurrently_ (watchRepo opts logger) (optRepos opts) mapConcurrently_ (watchRepo opts logger) (optRepos opts)
{- | Watch a single repository for changes. {- | Watch a single repository for changes.
@@ -183,7 +246,7 @@ watchRepo :: Options -> Logger -> RepoConfig -> IO ()
watchRepo opts logger repo = do watchRepo opts logger repo = do
absPath <- resolvePath (rcPath repo) absPath <- resolvePath (rcPath repo)
let debounceUs = optDebounceMs opts * 1000 let debounceUs = optDebounceMs opts * 1000
logRepo logger repo ("watching " <> T.pack absPath <> " (debounce: " <> T.pack (show (optDebounceMs opts)) <> "ms)") logRepo logger repo ("watching " <> T.pack absPath <> " (debounce: " <> T.pack (show (optDebounceMs opts)) <> "ms) [level=" <> T.pack (show (optLogLevel opts)) <> "]")
pending <- newEmptyMVar pending <- newEmptyMVar
-- Background sync loop: waits for an event signal, debounces, then syncs -- Background sync loop: waits for an event signal, debounces, then syncs
_ <- forkIO $ forever $ do _ <- forkIO $ forever $ do
@@ -246,17 +309,17 @@ merge, rebase, revert, cherry-pick, or bisect.
-} -}
syncRepo :: Options -> Logger -> RepoConfig -> IO () syncRepo :: Options -> Logger -> RepoConfig -> IO ()
syncRepo opts logger repo = do syncRepo opts logger repo = do
logRepo logger repo "sync cycle starting" logRepoAt logger Debug repo "sync cycle starting"
-- Guard: bail out if an operation (merge, rebase, revert, etc.) is in progress -- Guard: bail out if an operation (merge, rebase, revert, etc.) is in progress
inProgress <- isInMiddleOfOperation (rcPath repo) inProgress <- isInMiddleOfOperation (rcPath repo)
when inProgress $ do when inProgress $ do
logRepo logger repo "*** SKIPPING: an operation (merge/rebase/revert/cherry-pick/bisect) is in progress ***" logRepoAt logger Warn repo "*** SKIPPING: an operation (merge/rebase/revert/cherry-pick/bisect) is in progress ***"
unless inProgress $ do unless inProgress $ do
-- Check what's changed -- Check what's changed
(_, statusOut, _) <- runGitIn (rcPath repo) ["status", "--porcelain"] (_, statusOut, _) <- runGitIn (rcPath repo) ["status", "--porcelain"]
let changed = filter (not . T.null) (T.lines statusOut) let changed = filter (not . T.null) (T.lines statusOut)
if null changed if null changed
then logRepo logger repo "no changes to commit" then logRepoAt logger Debug repo "no changes to commit"
else do else do
let count = length changed let count = length changed
let preview = T.unlines (take 15 changed) let preview = T.unlines (take 15 changed)
@@ -271,7 +334,7 @@ syncRepo opts logger repo = do
|| "nothing to commit" `T.isInfixOf` commitErr -> || "nothing to commit" `T.isInfixOf` commitErr ->
logRepo logger repo "nothing to commit (working tree clean)" logRepo logger repo "nothing to commit (working tree clean)"
| otherwise -> do | otherwise -> do
logRepo logger repo ("commit FAILED (exit " <> T.pack (show commitCode) <> "): " <> T.strip (commitOut <> commitErr)) logRepoAt logger Error repo ("commit FAILED (exit " <> T.pack (show commitCode) <> "): " <> T.strip (commitOut <> commitErr))
notifyUser notifyUser
opts opts
"Converge: Commit Failed" "Converge: Commit Failed"
@@ -286,7 +349,7 @@ syncRepo opts logger repo = do
then logRepo logger repo "pull: already up to date" then logRepo logger repo "pull: already up to date"
else logRepo logger repo ("pull succeeded:\n" <> trimmed) else logRepo logger repo ("pull succeeded:\n" <> trimmed)
_ -> do _ -> do
logRepo logger repo ("pull FAILED (exit " <> T.pack (show pullCode) <> "): " <> T.strip (pullOut <> pullErr)) logRepoAt logger Error repo ("pull FAILED (exit " <> T.pack (show pullCode) <> "): " <> T.strip (pullOut <> pullErr))
notifyUser notifyUser
opts opts
"Converge: Pull Failed" "Converge: Pull Failed"
@@ -295,7 +358,7 @@ syncRepo opts logger repo = do
conflicted <- hasConflicts (rcPath repo) conflicted <- hasConflicts (rcPath repo)
if conflicted if conflicted
then do then do
logRepo logger repo "*** MERGE CONFLICT detected! ***" logRepoAt logger Error repo "*** MERGE CONFLICT detected! ***"
notifyUser notifyUser
opts opts
"Converge: Merge Conflict" "Converge: Merge Conflict"
@@ -308,7 +371,7 @@ syncRepo opts logger repo = do
ExitSuccess -> ExitSuccess ->
logRepo logger repo "push succeeded" logRepo logger repo "push succeeded"
_ -> do _ -> do
logRepo logger repo ("push FAILED (exit " <> T.pack (show pushCode) <> "): " <> T.strip (pushOut <> pushErr)) logRepoAt logger Error repo ("push FAILED (exit " <> T.pack (show pushCode) <> "): " <> T.strip (pushOut <> pushErr))
notifyUser notifyUser
opts opts
"Converge: Push Failed" "Converge: Push Failed"
@@ -323,7 +386,7 @@ gitCommitAll :: Logger -> FilePath -> IO (ExitCode, Text, Text)
gitCommitAll logger repoPath = do gitCommitAll logger repoPath = do
addResult@(addCode, _, addErr) <- loggedGit logger repoPath ["add", "-A"] addResult@(addCode, _, addErr) <- loggedGit logger repoPath ["add", "-A"]
unless (T.null addErr) $ unless (T.null addErr) $
logInfo logger ("git add stderr: " <> addErr) logWarn logger ("git add stderr: " <> addErr)
if addCode /= ExitSuccess if addCode /= ExitSuccess
then pure addResult then pure addResult
else do else do
@@ -349,14 +412,17 @@ gitPush logger repo =
-- | Run a git command and log the invocation and exit code. -- | Run a git command and log the invocation and exit code.
loggedGit :: Logger -> FilePath -> [String] -> IO (ExitCode, Text, Text) loggedGit :: Logger -> FilePath -> [String] -> IO (ExitCode, Text, Text)
loggedGit logger repoPath args = do loggedGit logger repoPath args = do
logInfo logger ("RUN: git -C " <> T.pack repoPath <> " " <> T.intercalate " " (map T.pack args)) logDebug logger ("RUN: git -C " <> T.pack repoPath <> " " <> T.intercalate " " (map T.pack args))
result@(code, _, err) <- runGitIn repoPath args result@(code, _, err) <- runGitIn repoPath args
let msg = "EXIT " <> T.pack (show code) let msg = "EXIT " <> T.pack (show code)
details = details =
if code /= ExitSuccess && not (T.null err) if code /= ExitSuccess && not (T.null err)
then ": " <> T.strip err then ": " <> T.strip err
else "" else ""
logInfo logger (msg <> details) let exitLevel = case code of
ExitSuccess -> Debug
_ -> Error
logMsg logger exitLevel (msg <> details)
pure result pure result
-- | Check if the repository currently has merge conflicts. -- | Check if the repository currently has merge conflicts.
+4 -3
View File
@@ -16,12 +16,13 @@ module TestHelper (
) where ) where
import Control.Concurrent.MVar (newMVar) import Control.Concurrent.MVar (newMVar)
import Converge (Logger (..)) import Converge (Logger (..), LogLevel (..))
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import System.Directory (createDirectory) import System.Directory (createDirectory)
import System.Exit (ExitCode) import System.Exit (ExitCode)
import System.FilePath ((</>)) import System.FilePath ((</>))
import System.IO (stderr)
import System.IO.Temp (withSystemTempDirectory) import System.IO.Temp (withSystemTempDirectory)
import System.Process (readProcessWithExitCode) import System.Process (readProcessWithExitCode)
@@ -37,9 +38,9 @@ rawGit args = do
(code, out, err) <- readProcessWithExitCode "git" args "" (code, out, err) <- readProcessWithExitCode "git" args ""
pure (code, T.pack out, T.pack err) pure (code, T.pack out, T.pack err)
-- | Create a test logger (serialized writes to stderr). -- | Create a test logger (serialized writes to stderr, Debug level).
testLogger :: IO Logger testLogger :: IO Logger
testLogger = Logger <$> newMVar () testLogger = Logger <$> newMVar () <*> pure Debug <*> pure stderr
{- | Create a temporary git repository, configure a user, make an initial {- | Create a temporary git repository, configure a user, make an initial
commit on branch "main", run the action with the repo path, then clean up. commit on branch "main", run the action with the repo path, then clean up.