packages feed

pushme-2.0.1: Main.hs

{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternGuards #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE ViewPatterns #-}

{-# OPTIONS_GHC -fno-warn-orphans #-}

module Main where

import           Control.Arrow
import           Control.Concurrent.ParallelIO (stopGlobalPool, parallel_)
import           Control.Exception
import qualified Control.Foldl as L
import           Control.Lens hiding (argument)
import           Control.Logging
import           Control.Monad
import           Control.Monad.Trans.Reader
import           Data.Aeson
import qualified Data.ByteString as B (readFile)
import           Data.Char (isDigit)
import           Data.Data (Data)
import           Data.List
import           Data.Map (Map)
import qualified Data.Map as M
import           Data.Maybe (catMaybes, fromMaybe, fromJust, isNothing)
import           Data.Monoid (mempty)
import           Data.Ord (comparing)
import           Data.Text (Text, pack, unpack)
import qualified Data.Text as T
import qualified Data.Text.Format as Fmt
import qualified Data.Text.Format.Params as Fmt
import           Data.Text.Lazy (toStrict)
import           Data.Typeable (Typeable)
import           Data.Yaml (decode)
import           Filesystem
import           Filesystem.Path.CurrentOS hiding (null, concat)
import           GHC.Conc (setNumCapabilities)
import           Options.Applicative hiding (Success, (&))
import           Pipes as P
import qualified Pipes.Group as P
import qualified Pipes.Prelude as P
import           Pipes.Safe as P hiding (try, finally)
import qualified Pipes.Text as Text
import qualified Pipes.Text.Encoding as Text
import qualified Pipes.Text.IO as Text
import           Prelude hiding (FilePath)
import           Safe hiding (at)
import           Shelly.Lifted hiding ((</>), find, trace)
import           Text.Printf (printf)
import           Text.Regex.Posix ((=~))

--import Debug.Trace

version :: String
version = "2.0.0.1"

copyright :: String
copyright = "2013-4"

pushmeSummary :: String
pushmeSummary =
    "pushme " ++ version ++ ", (C) " ++ copyright ++ " John Wiegley"

data Options = Options
    { jobs     :: Int
    , dryRun   :: Bool
    , noSync   :: Bool
    , copyAll  :: Bool
    , dump     :: Bool
    , ssh      :: String
    , rsyncOpt :: String
    , checksum :: Bool
    , fromName :: String
    , filesets :: String
    , classes  :: String
    , siUnits  :: Bool
    , verbose  :: Bool
    , quiet    :: Bool
    , cliArgs  :: [String]
    }
    deriving (Data, Typeable, Show, Eq)

pushmeOpts :: Parser Options
pushmeOpts = Options
    <$> option auto
        (   short 'j'
         <> long "jobs"
         <> value 1
         <> help "Run INT concurrent finds at once (default: 1)")
    <*> switch
        (   short 'n'
         <> long "dry-run"
         <> help "Don't take any actions")
    <*> switch
        (   short 'N'
         <> long "no-sync"
         <> help "Don't even attempt a dry-run sync")
    <*> switch
        (   long "copy-all"
         <> help "For git-annex directories, copy all files")
    <*> switch
        (   long "dump"
         <> help "Show all the stores that would be synced")
    <*> strOption
        (   long "ssh"
         <> value ""
         <> help "Use a specific ssh command")
    <*> strOption
        (   long "rsync"
         <> value ""
         <> help "Use a specific rsync command")
    <*> switch
        (   long "checksum"
         <> help "Pass --checksum flag to rsync")
    <*> strOption
        (   long "from"
         <> value ""
         <> help "Provide the name of the current host")
    <*> strOption
        (   short 'f'
         <> long "filesets"
         <> value ""
         <> help "Synchronize the given fileset(s) (comma-sep)")
    <*> strOption
        (   short 'c'
         <> long "classes"
         <> value ""
         <> help "Filesets classes to synchronize (comma-sep)")
    <*> switch
        (   long "si"
         <> help "Use 1000 instead of 1024 to divide")
    <*> switch
        (   short 'v'
         <> long "verbose"
         <> help "Report progress verbosely")
    <*> switch
        (   short 'q'
         <> long "quiet"
         <> help "Be a little quieter")
   <*> many (argument (eitherReader Right) (metavar "ARGS"))
    -- <*> many (argument Just (metavar "ARGS"))

data Rsync = Rsync
    { _rsyncPath          :: FilePath
    , _rsyncName          :: Maybe Text
    , _rsyncFilters       :: [Text]
    , _rsyncReportMissing :: Bool
    }
    deriving (Show, Eq)

defaultRsync :: FilePath -> Rsync
defaultRsync p = Rsync p Nothing [] False

instance FromJSON FilePath where
    parseJSON = fmap fromText . parseJSON

instance FromJSON Rsync where
    parseJSON (Object v) = Rsync
        <$> v .:  "Path"
        <*> v .:? "Host"
        <*> v .:? "Filters"       .!= []
        <*> v .:? "ReportMissing" .!= False
    parseJSON _ = errorL "Error parsing Rsync"

makeLenses ''Rsync

data Zfs = Zfs
    { _zfsPath     :: FilePath
    , _zfsPoolPath :: FilePath
    }
    deriving (Show, Eq)

instance FromJSON Zfs where
    parseJSON (Object v) = Zfs
        <$> v .: "Path"
        <*> v .: "PoolPath"
    parseJSON _ = errorL "Error parsing Zfs"

makeLenses ''Zfs

data Annex = Annex
    { _annexPath      :: FilePath
    , _annexName      :: Maybe Text
    , _annexFlags     :: [Text]
    , _annexIsPrimary :: Bool
    }
    deriving (Show, Eq)

instance FromJSON Annex where
    parseJSON (Object v) = do
        p <- v .: "Path"
        Annex <$> pure p
              <*> v .:? "Name"
              <*> v .:? "Flags"   .!= []
              <*> v .:? "Primary" .!= False
    parseJSON _ = errorL "Error parsing Annex"

makeLenses ''Annex

data StorageScheme
    = SchemeRsync Rsync
    | SchemeZfs Zfs
    | SchemeAnnex Annex
    deriving (Show, Eq)

makePrisms ''StorageScheme

data Store = Store
    { _schemes :: Map Text StorageScheme
    } deriving (Show, Eq)

makeLenses ''Store

instance FromJSON Store where
    parseJSON (Object v) = do
        mpath  <- fmap (SchemeRsync . defaultRsync) <$> v .:? "Path"
        mrsync <- fmap SchemeRsync <$> v .:? "Rsync"
        mzfs   <- fmap SchemeZfs   <$> v .:? "Zfs"
        mannex <- fmap SchemeAnnex <$> v .:? "Annex"
        return $ Store mempty
            & schemes.at "rsync" .~ (mrsync <|> mpath)
            & schemes.at "zfs"   .~ mzfs
            & schemes.at "annex" .~ mannex
    parseJSON _ = errorL "Error parsing Store"

rsyncScheme :: Traversal' Store Rsync
rsyncScheme = schemes.ix "rsync"._SchemeRsync

zfsScheme :: Traversal' Store Zfs
zfsScheme = schemes.ix "zfs"._SchemeZfs

annexScheme :: Traversal' Store Annex
annexScheme = schemes.ix "annex"._SchemeAnnex

data Fileset = Fileset
    { _fsName          :: Text
    , _fsClass         :: Text
    , _fsPriority      :: Int
    , _stores          :: Map Text Store
    } deriving (Show, Eq)

makeLenses ''Fileset

fromJSON' :: FromJSON a => Value -> a
fromJSON' a = case fromJSON a of
    Error e -> errorL (pack e)
    Success x -> x

instance FromJSON Fileset where
    parseJSON (Object v) = do
        fset <- Fileset
            <$> v .:  "Name"
            <*> v .:? "Class"    .!= ""
            <*> v .:? "Priority" .!= 1000
            <*> v .:? "Stores"   .!= mempty
        opts <- v .:? "Options" .!= mempty
        return $ M.foldlWithKey' f fset (opts :: Map Text (Map Text Value))
      where
        f fs "Rsync" =
            M.foldlWithKey' k fs
          where
            k fs' "Filters" xs =
                fs' & stores.traverse.rsyncScheme.rsyncFilters <>~ fromJSON' xs
            k fs' _ _ = fs'

        f fs "Zfs"   = const fs

        f fs "Annex" =
            M.foldlWithKey' k fs
          where
            k fs' "Flags" xs =
                fs' & stores.traverse.annexScheme.annexFlags <>~ fromJSON' xs
            k fs' _ _ = fs'

        f fs _       = const fs
    parseJSON _ = errorL "Error parsing Fileset"

data Host = Host
    { _hostName    :: Text
    , _hostAliases :: [Text]
    }
    deriving (Show, Eq)

defaultHost :: Text -> Host
defaultHost n = Host n []

makeLenses ''Host

data BindingCommand = BindingSync | BindingSnapshot
    deriving (Show, Eq)

makePrisms ''BindingCommand

data Binding = Binding
    { _fileset     :: Fileset
    , _source      :: Host
    , _target      :: Host
    , _this        :: Store
    , _that        :: Store
    , _bindCommand :: BindingCommand
    } deriving (Show, Eq)

makeLenses ''Binding

isLocal :: Binding -> Bool
isLocal bnd =
       bnd^.source.hostName == bnd^.target.hostName
    || bnd^.source.hostName `elem` bnd^.target.hostAliases

targetHost :: Binding -> Maybe Host
targetHost bnd | isLocal bnd = Nothing
               | otherwise   = Just (bnd^.target)

data ExeMode = Normal | Sudo | SudoAsRoot

data ExeEnv = ExeEnv
    { exeMode    :: ExeMode
    , exeRemote  :: Maybe Host
    , exeCwd     :: Maybe FilePath
    , exeDiscard :: Bool         -- ^ Discard process output.
    , exeFindCmd :: Bool         -- ^ Look for command with "which".
    }

type App a = ReaderT Options Sh a

defaultExeEnv :: ExeEnv
defaultExeEnv = ExeEnv Normal Nothing Nothing False True

env :: Binding -> ExeEnv
env bnd = ExeEnv Normal (targetHost bnd) Nothing False True

sudoEnv :: Binding -> ExeEnv
sudoEnv bnd = (env bnd) { exeMode = Sudo }

main :: IO ()
main = withStdoutLogging $ do
    opts <- execParser optsDef

    when (dryRun opts || noSync opts) $
        warn' "`--dryrun' specified, no changes will be made!"

    _ <- GHC.Conc.setNumCapabilities (jobs opts)

    setLogLevel $ if verbose opts then LevelDebug else LevelInfo
    setLogTimeFormat "%H:%M:%S"

    hosts <- readHostsFile
    processBindings opts hosts `finally` stopGlobalPool
  where
    optsDef = info
        (helper <*> pushmeOpts)
        (fullDesc <> progDesc "" <> header pushmeSummary)

readHostsFile :: IO (Map Text Host)
readHostsFile = do
    hostsFile <- getHomePath (".pushme" </> "hosts")
    exists <- isFile hostsFile
    if exists
        then do
            hosts <- runSafeT $ P.toListM $
                L.purely P.folds L.mconcat
                    (Text.readFile (encodeString hostsFile) ^. Text.lines)
                >-> P.map (\l -> let (x:xs) = T.words l
                                     h = Host x xs
                                 in (x,h) : map (,h) xs)
            return $ M.fromList (concat hosts)
        else
            return mempty

directoryContents :: FilePath -> Producer FilePath IO ()
directoryContents topPath = do
    names <- lift $ listDirectory topPath
    let properNames =
            filter (`notElem` [".", "..", ".DS_Store", ".localized"]) names
    forM_ properNames $ \name -> yield (topPath </> name)

readFilesets :: IO (Map Text Fileset)
readFilesets = do
    confD <- getHomePath (".pushme" </> "conf.d")
    exists <- isDirectory confD
    unless exists $
        errorL $ "Please define filesets, "
            <> "using files named ~/.pushme/conf.d/<name>.yml"

    fmap (M.fromList . map ((^.fsName) &&& id))
        $ P.toListM
        $ directoryContents confD
            >-> P.filter (\n -> extension n == Just "yml")
            >-> P.mapM (liftIO . readDataFile)

readDataFile :: FromJSON a => FilePath -> IO a
readDataFile p = do
    d  <- Data.Yaml.decode <$> B.readFile (encodeString p)
    case d of
        Nothing -> errorL $ "Failed to read file " <> toTextIgnore p
        Just d' -> return d'

processBindings :: Options -> Map Text Host -> IO ()
processBindings opts hosts = do
    fsets    <- readFilesets
    thisHost <- T.init <$> shelly (silently $ cmd "hostname")
    let dflt = defaultHost (pack (fromName opts))
        here = hosts^.at thisHost.non dflt.hostName
    when (T.null here) $
        errorL "Please identify the current host using --from"
    parallel_
        $ map (applyBinding opts)
        $ relevantBindings opts here hosts fsets

relevantBindings :: Options -> Text -> Map Text Host -> Map Text Fileset
                 -> [Binding]
relevantBindings opts thisHost hosts fsets
    = sortBy (comparing (^.fileset.fsPriority))
    $ filter matching
    $ catMaybes
    $ createBinding
        <$> M.elems fsets
        <*> pure thisHost
        <*> map pack (cliArgs opts)
  where
    matching bnd =
           (T.null fss || matchText fss (fs^.fsName))
         && (T.null cls || matchText cls (fs^.fsClass))
      where
        fs  = bnd^.fileset
        fss = pack (filesets opts)
        cls = pack (classes opts)

    getHost h = fromMaybe (Host h []) (hosts^.at h)

    createBinding :: Fileset -> Text -> Text -> Maybe Binding
    createBinding fs hereRaw thereRaw = do
        let atsign = T.head thereRaw == '@'
            f | atsign = second T.tail
              | "/" `T.isInfixOf` thereRaw =
                  let [b, e] = T.splitOn "/" thereRaw
                  in const (b, e)
              | otherwise = id
            (here, there) = f (hereRaw, thereRaw)
        Binding
            <$> pure fs
            <*> pure (getHost here)
            <*> pure (getHost there)
            <*> fs^.stores.at here
            <*> fs^.stores.at there
            <*> pure (if atsign
                      then BindingSnapshot
                      else BindingSync)

applyBinding :: Options -> Binding -> IO ()
applyBinding opts bnd
    | dump opts =
        printBinding bnd
    | bnd^.bindCommand == BindingSnapshot =
        shelly $ runReaderT (snapshotBinding bnd) opts
    | otherwise =
        shelly $ silently $ runReaderT (syncBinding bnd) opts

printBinding :: Binding -> IO ()
printBinding bnd = do
    go (bnd^.fileset) (bnd^.this)
    go (bnd^.fileset) (bnd^.that)
  where
    go fs c = putStrLn $ printf "%-12s %s"
        (unpack (fs^.fsName)) (show (c^.schemes))

snapshotBinding :: Binding -> App ()
snapshotBinding bnd@((^? that.zfsScheme) -> Just z) = do
    mrev <- determineLastRev (env bnd) z
    let nextRev = maybe 1 succ mrev
        thatSnapshot =
            toTextIgnore $ z^.zfsPoolPath <> "@" <> decodeString (show nextRev)
    liftIO $ log' $ format "Creating snapshot {}" [thatSnapshot]
    execute_ (env bnd) "zfs" ["snapshot", thatSnapshot]
snapshotBinding _ = return ()

syncBinding :: Binding -> App ()
syncBinding bnd = errExit False $ do
    liftIO $ log' $ format "Sending {}/{} -> {}"
        [ bnd^.source.hostName
        , bnd^.fileset.fsName
        , bnd^.target.hostName
        ]
    syncStores bnd (bnd^.this) (bnd^.that)

syncStores :: Binding -> Store -> Store -> App ()
syncStores bnd ((^? annexScheme) -> Just a1) ((^? annexScheme) -> Just a2) =
    syncAnnexSchemes bnd a1 a2
syncStores bnd ((^? zfsScheme) -> Just z1) ((^? zfsScheme) -> Just z2) =
    syncZfsSchemes bnd z1 z2
syncStores bnd s1 s2 = syncUsingRsync bnd s1 s2

checkDirectory :: Binding -> FilePath -> Bool -> App Bool
checkDirectory _ path False  = test_d path
checkDirectory (isLocal -> True) path True = test_d path
checkDirectory bnd path True = return True -- do
    -- execute (env bnd) "test" ["-d", toTextIgnore path]
    -- (== 0) <$> lastExitCode

getStorePath :: Binding -> Store -> Bool -> Maybe FilePath
getStorePath bnd s wantTarget
    =   (s^?rsyncScheme.rsyncPath)
    <|> (s^?zfsScheme.zfsPath)
    <|> (s^?annexScheme.annexPath)
    <|> errorL ("Could not find path for "
                <> ((if wantTarget
                     then bnd^.target
                     else bnd^.source)^.hostName)
                <> "/" <> (bnd^.fileset.fsName))

syncAnnexSchemes :: Binding -> Annex -> Annex -> App ()
syncAnnexSchemes bnd a1 a2 = do
    opts <- ask
    exists1 <- checkDirectory bnd (a1^.annexPath) False
    exists2 <- checkDirectory bnd (a2^.annexPath) True
    if exists1 && exists2
        then do
        let runner1_ = execute_ $
                (env bnd) { exeCwd    = Just (a1^.annexPath)
                          , exeRemote = Nothing
                          }
            runner2_ = execute_ $
                (env bnd) { exeCwd     = Just (a2^.annexPath)
                          , exeFindCmd = isLocal bnd
                          }

        -- Add, copy, and sync from the source.
        runner1_ "git-annex" $ ["-q" | not (verbose opts)]
            <> ["add", "-c", "alwayscommit=false", "."]
        runner1_ "git-annex" $ ["-q" | not (verbose opts)]
            <> [ "--auto"
               | not (a2^.annexIsPrimary || copyAll opts) ]
            <> [ "copy", "-c", "alwayscommit=false" ]
            <> [ "--not", "--in", annexTarget ]
            <> a1^.annexFlags
            <> [ "--to", annexTarget ]
        runner1_ "git-annex" $ ["-q" | not (verbose opts)] <> ["sync"]

        -- Sync to the destination.
        runner2_ "git-annex" $ ["-q" | not (verbose opts)] <> ["sync"]

        liftIO $ log' $ format "{}: Git Annex synchronized"
            [ bnd^.fileset.fsName ]

        else liftIO $ warn $ "Remote directory missing: "
                 <> toTextIgnore (a2^.annexPath)
  where
    annexTarget = a2^.annexName.non (bnd^.target.hostName)

syncZfsSchemes :: Binding -> Zfs -> Zfs -> App ()
syncZfsSchemes bnd z1 z2 = do
    exists1 <- checkDirectory bnd (z1^.zfsPath) False
    exists2 <- checkDirectory bnd (z2^.zfsPath) True
    if exists1 && exists2
        then do
        rev1 <- determineLastRev (env bnd) { exeRemote = Nothing } z1
        rev2 <- determineLastRev (env bnd) z2
        opts <- ask
        let p = z1^.zfsPoolPath
            r = toTextIgnore (z2^.zfsPoolPath)
            msendArgs = case (rev1, rev2) of
                (Just thisRev, Just thatRev) ->
                    if thisRev > thatRev
                    then Just $ sendTwoRevs opts p thatRev thisRev
                    else Nothing
                (Just thisRev, Nothing) ->
                    Just $ sendRev opts p thisRev
                (Nothing, _) ->
                    Just $ send (toTextIgnore p)
            env'' = defaultExeEnv { exeMode = Sudo }

        case msendArgs of
            Nothing      -> liftIO $ warn "Remote has newer snapshot revision"
            Just (c, xs) ->
                execute_ env'' c $ xs <> ["|", "zfs", "recv", "-F", r]

        else liftIO $ warn $ "Remote directory missing: "
                 <> toTextIgnore (z2^.zfsPath)
  where
    send pool = ("zfs", ["send", pool])

    sendRev opts poolPath r1 =
        ("zfs",
         ["send"]
         <> ["-v" | verbose opts]
         <> [ toTextIgnore poolPath <> "@" <> tshow r1 ])

    sendTwoRevs opts poolPath r1 r2 =
        ("zfs",
         ["send"]
         <> ["-v" | verbose opts]
         <> [ "-I"
            , toTextIgnore poolPath <> "@" <> tshow r1
            , toTextIgnore poolPath <> "@" <> tshow r2
            ])

determineLastRev :: ExeEnv -> Zfs -> App (Maybe Int)
determineLastRev env' zfs = do
    let p = toTextIgnore $ (zfs^.zfsPath) </> ".zfs" </> "snapshot"
    fmap lastMay
          $ sort
        <$> map (read . unpack)
        <$> filter (T.all isDigit)
        <$> T.lines
        <$> execute env' "ls" ["-1", p]

syncUsingRsync :: Binding -> Store -> Store -> App ()
syncUsingRsync bnd s1 s2 = do
    exists1 <- checkDirectory bnd l False
    exists2 <- checkDirectory bnd r True
    if exists1 && exists2
        then
        rsync
            (bnd^.fileset)
            (fromMaybe (defaultRsync l) (s1^?rsyncScheme))
            l
            (fromMaybe (defaultRsync r) (s2^?rsyncScheme))
            (case h of
                  Nothing   -> toTextIgnore r
                  Just targ -> format "{}:{}" [targ, escape (toTextIgnore r)])

        else liftIO $ warn $ "Remote directory missing: " <> toTextIgnore r
  where
    h = case targetHost bnd of
        Nothing -> Nothing
        Just targ
            | Just (Just n) <- s2^?rsyncScheme.rsyncName -> Just n
            | otherwise -> Just (targ^.hostName)

    Just (asDirectory -> l) = getStorePath bnd s1 False
    Just (asDirectory -> r) = getStorePath bnd s2 True

rsync :: Fileset -> Rsync -> FilePath -> Rsync -> Text -> App ()
rsync fs srcRsync src destRsync dest = do
    let rfs   = (srcRsync^.rsyncFilters) <> (destRsync^.rsyncFilters)
        go xs = doRsync (fs^.fsName) xs (toTextIgnore src) dest
    case rfs of
        [] -> go []

        filters -> do
            when (srcRsync^.rsyncReportMissing) $
                liftIO $ reportMissingFiles fs srcRsync

            withTmpDir $ \p -> do
                let fpath = p </> "filters"
                writefile fpath (T.unlines filters)
                go ["--include-from=" <> toTextIgnore fpath]

reportMissingFiles :: Fileset -> Rsync -> IO ()
reportMissingFiles fs r =
    runEffect
        $ for (directoryContents rpath
               >-> P.map (T.drop len . toTextIgnore)
               >-> P.catch (P.filter (\x -> not (any (matchText x) patterns)))
                           (\(_ :: SomeException) -> P.cat))
        $ \f -> liftIO $ warn' $ format "{}: unknown: \"{}\"" [label, f]
  where
    label   = fs^.fsName
    rpath   = asDirectory (r^.rsyncPath)
    len     = T.length (toTextIgnore rpath)
    filters = r^.rsyncFilters

    patterns
        = map regexToGlob
        $ filter (`notElem` ["*", "*/", ".*", ".*/"])
        $ map stringify filters

    stringify
        = (\x -> if T.head x == '/' then T.tail x else x)
        . (\x -> if T.index x (T.length x - 1) == '/'
                 then T.init x else x)
        . T.drop 2

    regexToGlob
        = T.replace "].*" "]*"
        . T.replace "*" ".*"
        . T.replace "?" "."
        . T.replace "." "\\."

doRsync :: Text -> [Text] -> Text -> Text -> App ()
doRsync label options src dest = do
    opts <- ask
    let den      = (\x -> if x then 1000 else 1024) $ siUnits opts
        sshCmd   = ssh opts
        rsyncCmd = rsyncOpt opts
        toRemote = ":" `T.isInfixOf` dest
        args     =
            [ "-aHEy"               -- jww (2012-09-23): maybe -A too?
            -- , "--fileflags"
            , "--delete-after"
            , "--ignore-errors"
            , "--force"

            , "--exclude=/.Caches/"
            , "--exclude=/.Spotlight-V100/"
            , "--exclude=/.TemporaryItems/"
            , "--exclude=/.Trash/"
            , "--exclude=/.Trashes/"
            , "--exclude=/.fseventsd/"
            , "--exclude=/.zfs/"
            , "--exclude=/Temporary Items/"
            , "--exclude=/Network Trash Folder/"

            , "--filter=-p .DS_Store"
            , "--filter=-p .localized"
            , "--filter=-p .AppleDouble/"
            , "--filter=-p .AppleDB/"
            , "--filter=-p .AppleDesktop/"
            , "--filter=-p .com.apple.timemachine.supported"
            ]
            <> (if not (null sshCmd)
                then ["--rsh", pack sshCmd]
                else [])
            <> ["-n" | dryRun opts]
            <> ["--checksum" | checksum opts]
            <> (if verbose opts then ["-P"] else ["--stats"])
            <> [pack ("--rsync-path=sudo " ++ if not (null rsyncCmd)
                                              then rsyncCmd
                                              else "rsync") | toRemote]
            <> options
            <> [src, dest]
        analyze = not (verbose opts) && not (noSync opts)
        env' =  defaultExeEnv
            { exeMode    = if toRemote then SudoAsRoot else Sudo
            , exeDiscard = not analyze
            }

    output <- execute env' "rsync" args
    when analyze $ do
        let stats = M.fromList
                $ map (fmap (T.filter (/= ',') . (!! 1) . T.words)
                           . T.breakOn ": ")
                $ filter (": " `T.isInfixOf`)
                $ T.lines output
            files = field "Number of files" stats
            sent  = field "Number of regular files transferred" stats
                <|> field "Number of files transferred" stats
            total = field "Total file size" stats
            xfer  = field "Total transferred file size" stats
        liftIO $ log' $ format
            ("{}: \ESC[34mSent \ESC[35m{}\ESC[0m\ESC[34m "
                <> "in {} files\ESC[0m (out of {} in {})")
            [ label
            , humanReadable den (fromMaybe 0 xfer)
            , commaSep (fromIntegral (fromMaybe 0 sent))
            , humanReadable den (fromMaybe 0 total)
            , commaSep (fromIntegral (fromMaybe 0 files))
            ]
  where
    field :: Text -> M.Map Text Text -> Maybe Integer
    field x stats = read . unpack <$> M.lookup x stats

    commaSep :: Int -> Text
    commaSep = fst
        . T.foldr (\x (xs, num :: Int) ->
                    if num /= 0 && num `mod` 3 == 0
                    then (x `T.cons` ',' `T.cons` xs, num + 1)
                    else (x `T.cons` xs, num + 1)) ("", 0)
        . tshow

execute :: ExeEnv -> FilePath -> [Text] -> App Text
execute ExeEnv {..} name args = do
    opts    <- ask
    cmdName <- (if exeFindCmd then findCmd else return) name
    let (name', args') = case exeMode of
            Normal     -> (cmdName, args)
            Sudo       -> ("sudo", toTextIgnore cmdName:args)
            SudoAsRoot -> sudoAsRoot cmdName args
        (modifier, name'', args'')= case exeRemote of
            Nothing -> (id, name', args')
            Just h  ->
                remote opts h $ case exeCwd of
                    Nothing  -> (id, name', args')
                    Just cwd ->
                        (escaping False, fromText $ T.concat $
                             [ "\"cd "
                             , escape (toTextIgnore cwd)
                             , "; "
                             , escape (toTextIgnore name')
                             , " "
                             ]
                            <> intersperse " " (map escape args')
                            <> ["\""], [])
        runner p xs
            | exeDiscard = run_ p xs >> return ""
            | otherwise  = run p xs
        runner' p xs =
            (case exeCwd of
                  Just cwd | isNothing exeRemote -> chdir cwd
                  _ -> id) $ modifier $ runner p xs
    if dryRun opts || noSync opts
        then return ""
        else do
            n <- findCmd name''
            liftIO $ debug' $ format "{} {}" [toTextIgnore n, tshow args'']
            runner' n args''
  where
    findCmd n
        -- Assume commands with spaces in them are "known"
        | " " `T.isInfixOf` toTextIgnore n = return n
        | relative n = do
            c <- which n
            case c of
                Nothing -> errorL $ "Failed to find command: " <> toTextIgnore n
                Just c' -> return c'
        | otherwise  = return n

    remote :: Options -> Host -> (App a -> App a, FilePath, [Text])
           -> (App a -> App a, FilePath, [Text])
    remote opts host (m, p, xs) =
        let sshCmd = ssh opts
        in (m, if null sshCmd then "ssh" else decodeString sshCmd,
            host^.hostName : toTextIgnore p : xs)

    sudoAsRoot :: FilePath -> [Text] -> (FilePath, [Text])
    sudoAsRoot p xs =
        ("sudo", [ "su", "-", "root", "-c"
                 -- Pass the argument to su as a single, escaped string.
                 , T.unwords (map escape (toTextIgnore p:xs))
                 ])

execute_ :: ExeEnv -> FilePath -> [Text] -> App ()
execute_ env' fp args = void $ execute env' { exeDiscard = True } fp args

getHomePath :: FilePath -> IO FilePath
getHomePath p = (</> p) <$> getHomeDirectory

asDirectory :: FilePath -> FilePath
asDirectory (toTextIgnore -> fp) =
    fromText $ if T.null fp || T.last fp /= '/'
               then T.append fp "/"
               else fp

escape :: Text -> Text
escape x
    | "\"" `T.isInfixOf` x || " " `T.isInfixOf` x =
        "'" <> T.replace "\"" "\\\"" x <> "'"
    | otherwise = x

matchText :: Text -> Text -> Bool
matchText x y = unpack x =~ unpack y

tshow :: Show a => a -> Text
tshow = pack . show

format :: Fmt.Params a => Fmt.Format -> a -> Text
format = (toStrict .) . Fmt.format

humanReadable :: Integer -> Integer -> Text
humanReadable den x =
    pack $ fromJust
          $ f 0 "b"
        <|> f 1 "K"
        <|> f 2 "M"
        <|> f 3 "G"
        <|> f 4 "T"
        <|> f 5 "P"
        <|> f 6 "X"
        <|> Just (printf "%db" x)
  where
    f :: Integer -> String -> Maybe String
    f n s | x < (den^succ n) =
        Just $ if n == 0
               then printf ("%d" ++ s) x
               else printf ("%." ++ show (min 3 (pred n)) ++ "f" ++ s)
                   (fromIntegral x / (fromIntegral den^n :: Double))
    f _ _ = Nothing

-- Main.hs (pushme) ends here