Support multiple repositories per execution
- Add RepoConfig type for per-repo path/remote/branch - Options now holds [RepoConfig] instead of single repo - YAML config file support (--config) for multi-repo setup - --repo still works for quick single-repo usage - Concurrent watchers via async (one thread per repo) - --debounce flag overrides config file value
This commit is contained in:
+118
-45
@@ -1,10 +1,17 @@
|
||||
module Converge
|
||||
( -- * Core types
|
||||
Options (..),
|
||||
RepoConfig (..),
|
||||
defaultOptions,
|
||||
defaultRepoConfig,
|
||||
|
||||
-- * Watching
|
||||
withWatcher,
|
||||
-- * Config file
|
||||
Config (..),
|
||||
loadConfig,
|
||||
configToOptions,
|
||||
|
||||
-- * Running
|
||||
runConverge,
|
||||
|
||||
-- * Git operations
|
||||
gitCommitAll,
|
||||
@@ -17,65 +24,135 @@ module Converge
|
||||
where
|
||||
|
||||
import Control.Concurrent (threadDelay)
|
||||
import Control.Concurrent.Async (mapConcurrently_)
|
||||
import Control.Exception (bracket)
|
||||
import Control.Monad (forever, unless, when)
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Yaml (FromJSON (..), decodeFileThrow, withObject, (.:), (.:?))
|
||||
import GHC.Generics (Generic)
|
||||
import System.Directory (getCurrentDirectory)
|
||||
import System.Exit (ExitCode (..))
|
||||
import System.FilePath ((</>))
|
||||
import System.FilePath (isAbsolute, (</>))
|
||||
import System.FSNotify
|
||||
import System.IO (hPutStrLn, stderr)
|
||||
import System.Process (readProcessWithExitCode, spawnProcess, waitForProcess)
|
||||
import System.Process (readProcessWithExitCode, spawnProcess)
|
||||
|
||||
-- | Command-line / configuration options for converge.
|
||||
data Options = Options
|
||||
{ optRepoPath :: !FilePath
|
||||
-- ^ Path to the git repository to watch. Defaults to cwd.
|
||||
, optDebounceMs :: !Int
|
||||
-- ^ Debounce window in milliseconds. Default: 5000.
|
||||
, optRemote :: !Text
|
||||
-- ^ Remote name to pull from. Default: @"origin"@.
|
||||
, optBranch :: !Text
|
||||
-- ^ Branch to pull. Default: @"main"@.
|
||||
, optNotifyCmd :: !Text
|
||||
-- ^ Command used for desktop notifications. Default: @"notify-send"@.
|
||||
-- | 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)
|
||||
|
||||
-- | Sensible defaults.
|
||||
-- | 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
|
||||
{ optRepoPath = "."
|
||||
{ optRepos = [defaultRepoConfig]
|
||||
, optDebounceMs = 5000
|
||||
, optRemote = "origin"
|
||||
, optBranch = "main"
|
||||
, optNotifyCmd = "notify-send"
|
||||
}
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- File watcher
|
||||
-- Config file (YAML)
|
||||
----------------------------------------------------------------------
|
||||
|
||||
-- | Watch the repository for file changes, debounced.
|
||||
-- Calls the action after a quiet period of 'optDebounceMs' ms.
|
||||
withWatcher :: Options -> (Event -> IO ()) -> IO ()
|
||||
withWatcher opts onQuietPeriod = do
|
||||
repoPath <- if optRepoPath opts == "."
|
||||
then getCurrentDirectory
|
||||
else pure (optRepoPath opts)
|
||||
-- | Shape of the optional YAML config file.
|
||||
data Config = Config
|
||||
{ cfgRepos :: ![RepoConfig]
|
||||
, cfgDebounceMs :: !(Maybe Int)
|
||||
}
|
||||
deriving (Show, Eq, Generic)
|
||||
|
||||
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"
|
||||
<*> o .:? "remote" .!= "origin"
|
||||
<*> o .:? "branch" .!= "main"
|
||||
|
||||
-- | 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)
|
||||
}
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Running
|
||||
----------------------------------------------------------------------
|
||||
|
||||
-- | Watch all configured repositories concurrently.
|
||||
runConverge :: Options -> IO ()
|
||||
runConverge opts =
|
||||
mapConcurrently_ (watchRepo opts) (optRepos opts)
|
||||
|
||||
watchRepo :: Options -> RepoConfig -> IO ()
|
||||
watchRepo opts repo = do
|
||||
absPath <- resolvePath (rcPath repo)
|
||||
let debounceUs = optDebounceMs opts * 1000
|
||||
bracket
|
||||
(startManagerConf defaultConfig {confDebounce = NoDebounce})
|
||||
stopManager
|
||||
$ \mgr -> do
|
||||
_ <- watchDir mgr repoPath (const True) $ \_event -> do
|
||||
-- Debounce: wait for quiet period, then fire
|
||||
_ <- watchDir mgr absPath (const True) $ \_event -> do
|
||||
threadDelay debounceUs
|
||||
onQuietPeriod _event
|
||||
-- Never finish
|
||||
syncRepo opts repo
|
||||
forever $ threadDelay maxBound
|
||||
where
|
||||
resolvePath p
|
||||
| isAbsolute p = pure p
|
||||
| otherwise = (</> p) <$> getCurrentDirectory
|
||||
|
||||
-- | Run a full sync cycle on a single repo: commit + pull + conflict check.
|
||||
syncRepo :: Options -> RepoConfig -> IO ()
|
||||
syncRepo opts repo = do
|
||||
(commitCode, _, commitErr) <- gitCommitAll (rcPath repo)
|
||||
unless (commitCode == ExitSuccess) $
|
||||
hPutStrLn stderr ("[" <> rcPath repo <> "] git commit failed: " <> show commitErr)
|
||||
(pullCode, _, pullErr) <- gitPull repo
|
||||
unless (pullCode == ExitSuccess) $
|
||||
hPutStrLn stderr ("[" <> rcPath repo <> "] git pull failed: " <> show pullErr)
|
||||
conflicted <- hasConflicts (rcPath repo)
|
||||
when conflicted $
|
||||
notifyConflict opts repo
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Git operations
|
||||
@@ -84,37 +161,33 @@ withWatcher opts onQuietPeriod = do
|
||||
-- | Stage all changes and commit with an auto-generated message.
|
||||
gitCommitAll :: FilePath -> IO (ExitCode, Text, Text)
|
||||
gitCommitAll repoPath = do
|
||||
-- Stage everything
|
||||
(_, _, stageErr) <- runGitIn repoPath ["add", "-A"]
|
||||
unless (T.null stageErr) $
|
||||
hPutStrLn stderr ("git add stderr: " <> T.unpack stageErr)
|
||||
-- Commit
|
||||
let msg = "converge: auto-commit"
|
||||
runGitIn repoPath ["commit", "-m", T.unpack msg]
|
||||
runGitIn repoPath ["commit", "-m", "converge: auto-commit"]
|
||||
|
||||
-- | Pull from the configured remote and branch.
|
||||
gitPull :: Options -> IO (ExitCode, Text, Text)
|
||||
gitPull opts =
|
||||
runGitIn (optRepoPath opts)
|
||||
["pull", "--no-edit", T.unpack (optRemote opts), T.unpack (optBranch opts)]
|
||||
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
|
||||
let conflictMarkers = [<<"<<<<<<<", "=======", ">>>>>>>"]
|
||||
(_, diffOut, _) <- runGitIn repoPath ["diff", "--check"]
|
||||
pure $ any (`T.isInfixOf` diffOut) conflictMarkers
|
||||
pure $ any (`T.isInfixOf` diffOut) ["<<<<<<<", "=======", ">>>>>>>"]
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Notifications
|
||||
----------------------------------------------------------------------
|
||||
|
||||
-- | Send a desktop notification about a merge conflict.
|
||||
notifyConflict :: Options -> IO ()
|
||||
notifyConflict opts = do
|
||||
notifyConflict :: Options -> RepoConfig -> IO ()
|
||||
notifyConflict opts repo = do
|
||||
let cmd = T.unpack (optNotifyCmd opts)
|
||||
title = "Converge: Merge Conflict"
|
||||
body = "A merge conflict occurred in " <> optRepoPath opts
|
||||
body = "A merge conflict occurred in " <> T.pack (rcPath repo)
|
||||
<> ". Manual resolution required."
|
||||
_ <- spawnProcess cmd ["--urgency=critical", title, T.unpack body]
|
||||
pure ()
|
||||
|
||||
Reference in New Issue
Block a user