dojang-0.2.0: src/Dojang/Commands/Apply.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
module Dojang.Commands.Apply (apply) where
import Control.Monad (filterM, forM, forM_, unless, void, when)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.Reader (asks)
import Data.Function ((&))
import Data.List (nub, sort)
import System.Exit (ExitCode (..), exitWith)
import System.IO (hIsTerminalDevice, stderr, stdin)
import Prelude hiding (readFile)
import Control.Monad.Logger (logDebug, logDebugSH)
import Data.CaseInsensitive (original)
import Data.Map.Strict (fromList, notMember, toList)
import FortyTwo.Prompts.Confirm (confirm)
import System.Directory.OsPath (getHomeDirectory, makeAbsolute)
import System.OsPath
( OsPath
, addTrailingPathSeparator
, takeDirectory
, (</>)
)
import Dojang.App
( App
, AppEnv (debug, dryRun, manifestFile, sourceDirectory)
, ensureContext
, lookupEnv'
)
import Dojang.Commands
( Admonition (..)
, codeStyleFor
, die'
, pathStyleFor
, printStderr
, printStderr'
)
import Dojang.Commands.Hook (HookEnv (HookEnv), executeHooks)
import Dojang.Commands.Status (defaultStatusOptions, printWarnings, status)
import Dojang.ExitCodes
( accidentalDeletionWarning
, conflictError
, fileNotRoutedError
)
import Dojang.MonadFileSystem (MonadFileSystem (..), dryRunIO)
import Dojang.Types.Context
( Context (..)
, FileCorrespondence (..)
, FileDeltaKind (..)
, FileEntry (..)
, FileStat (..)
, RouteState (..)
, getRouteState
, makeCorrespond
)
import Dojang.Types.Environment (Environment (..))
import Dojang.Types.Hook (HookType (..))
import Dojang.Types.Registry
( Registry (..)
, readRegistry
, registryFilename
, writeRegistry
)
import Dojang.Types.Repository (Repository (..))
apply :: (MonadFileSystem i, MonadIO i) => Bool -> [OsPath] -> App i ExitCode
apply force filePaths = do
ctx <- ensureContext
-- Determine if this is the first apply (registry doesn't exist)
homeDir <- liftIO getHomeDirectory
let registryPath = homeDir </> registryFilename
existingRegistry <- readRegistry registryPath
let isFirstApply = case existingRegistry of
Nothing -> True
Just _ -> False
-- Build hook environment
sourceDir <- asks (.sourceDirectory)
manifestFile' <- asks (.manifestFile)
dryRun' <- asks (.dryRun)
let environment = ctx.environment
let hookEnv =
HookEnv
sourceDir
manifestFile'
dryRun'
(original environment.operatingSystem.identifier)
(original environment.architecture.identifier)
-- Run pre-apply hooks
$(logDebug) "Running pre-apply hooks..."
executeHooks hookEnv ctx PreApply
when isFirstApply $ do
$(logDebug) "Running pre-first-apply hooks..."
executeHooks hookEnv ctx PreFirstApply
(allFiles, ws) <- makeCorrespond ctx
fileMap <- fmap fromList $ forM allFiles $ \fc -> do
srcAbsPath <- liftIO $ makeAbsolute fc.source.path
return (srcAbsPath, fc)
pathStyle <- pathStyleFor stderr
filePaths' <- forM filePaths $ \fp -> do
fp' <- liftIO $ makeAbsolute fp
when (fp' `notMember` fileMap) $ do
die' fileNotRoutedError $
"File "
<> pathStyle fp
<> " is not tracked by this repository."
return fp'
let files =
if null filePaths'
then allFiles
else
[ f
| (srcAbsPath, f) <- toList fileMap
, srcAbsPath `elem` filePaths'
]
$(logDebugSH) files
-- Check if there are any conflicts:
conflicts <- filterConflicts files
codeStyle <- codeStyleFor stderr
unless (null conflicts) $ do
forM_ conflicts $ \c -> do
printStderr' (if force then Warning else Error) $
"There is a conflict between "
<> pathStyle c.source.path
<> " and "
<> pathStyle c.destination.path
<> "."
printStderr' Hint $
"Use `"
<> codeStyle "dojang diff"
<> "' to see the actual changes on both sides."
-- Check if there are any accidental deletions:
let ops =
syncSourceToIntermediate
[ (fc.source, fc.intermediate, fc.sourceDelta)
| fc <- files
]
& nub
. sort
shimOps <- liftIO $ dryRunIO $ do
forM_ ops doSyncOp
let ctx' = Context ctx.repository ctx.environment lookupEnv'
(files', _) <- makeCorrespond ctx'
syncIntermediateToDestination
ctx'
[ (fc.intermediate, fc.destination, fc.destinationDelta)
| fc <- files'
]
when (any isDeletion shimOps) $ do
forM_ shimOps $ \case
RemoveDirs path -> do
let path' = addTrailingPathSeparator path
if force
then
printStderr' Warning $
"Would delete "
<> pathStyle path'
<> " (and its children)."
else
printStderr' Error $
"Cancelled applying because "
<> pathStyle path'
<> " (and its children) would be deleted."
RemoveFile path -> do
if force
then printStderr' Warning ("Would delete " <> pathStyle path <> ".")
else
printStderr'
Error
$ "Cancelled applying because "
<> pathStyle path
<> " would be deleted."
_ -> return ()
printStderr' Hint $
"If these deletions are accidental, ignore them in your manifest ("
<> pathStyle manifestFile'
<> ")."
-- Exit if there are any problems (unless forced):
when (not force && (not (null conflicts) || any isDeletion shimOps)) $ do
printStderr' Hint $
"Use "
<> codeStyle "-f"
<> "/"
<> codeStyle "--force"
<> " to ignore these warnings and go ahead."
liftIO $
exitWith $
if not (null conflicts)
then conflictError
else accidentalDeletionWarning
-- When everything is fine (or excused):
debug' <- asks (.debug)
when debug' (void $ status defaultStatusOptions)
forM_ ops $ \path -> printSyncOp path >> doSyncOp path
when debug' (void $ status defaultStatusOptions)
(files', _) <- makeCorrespond ctx
$(logDebugSH) files'
ops' <-
syncIntermediateToDestination
ctx
[ (fc.intermediate, fc.destination, fc.destinationDelta)
| fc <- files'
]
forM_ (nub $ sort ops') $ \path -> printSyncOp path >> doSyncOp path
printWarnings ws
-- Run post-apply hooks
when isFirstApply $ do
$(logDebug) "Running post-first-apply hooks..."
executeHooks hookEnv ctx PostFirstApply
$(logDebug) "Running post-apply hooks..."
executeHooks hookEnv ctx PostApply
-- Update registry with current repository path:
currentRepo <- liftIO $ makeAbsolute ctx.repository.sourcePath
case existingRegistry of
Nothing -> do
-- No existing registry, create one
writeRegistry registryPath $ Registry currentRepo
Just reg
| reg.repositoryPath == currentRepo ->
-- Same repository, no need to update
return ()
| otherwise -> do
-- Different repository, ask user
isTerminal <- liftIO $ hIsTerminalDevice stdin
proceed <-
if isTerminal
then do
oldPath <- decodePath reg.repositoryPath
newPath <- decodePath currentRepo
let msg =
"The registry already points to "
<> oldPath
<> ". Overwrite with "
<> newPath
<> "?"
liftIO $ confirm msg
else
-- Non-interactive mode, just overwrite
return True
when proceed $ writeRegistry registryPath $ Registry currentRepo
return ExitSuccess
where
isDeletion :: SyncOp -> Bool
isDeletion (RemoveDirs _) = True
isDeletion (RemoveFile _) = True
isDeletion _ = False
-- TODO: This should be in another module:
filterConflicts
:: (MonadFileSystem i, MonadIO i)
=> [FileCorrespondence]
-> i [FileCorrespondence]
filterConflicts = filterM $ \c -> case (c.sourceDelta, c.destinationDelta) of
(Unchanged, _) -> return False
(_, Unchanged) -> return False
(Removed, Removed) -> return False
(Added, Added) -> case (c.source.stat, c.destination.stat) of
(Directory, Directory) -> return False
(File srcSize, File dstSize) ->
if srcSize /= dstSize
then return True
else do
src <- readFile c.source.path
dst <- readFile c.destination.path
return (src /= dst)
_ -> return True
_ -> return True
data SyncOp
= RemoveDirs OsPath
| RemoveFile OsPath
| CopyFile OsPath OsPath
| CreateDir OsPath
| CreateDirs OsPath
deriving (Eq, Show)
syncOpOrdKey :: SyncOp -> (OsPath, Int, OsPath)
syncOpOrdKey (RemoveFile path) = (takeDirectory path, 1, path)
syncOpOrdKey (RemoveDirs path) = (path, 2, path)
syncOpOrdKey (CreateDir path) = (path, 3, path)
syncOpOrdKey (CreateDirs path) = (path, 4, path)
syncOpOrdKey (CopyFile _ dst) = (takeDirectory dst, 5, dst)
instance Ord SyncOp where
compare a b = compare (syncOpOrdKey a) (syncOpOrdKey b)
printSyncOp :: (MonadIO i) => SyncOp -> App i ()
printSyncOp (RemoveDirs path) = do
pathStyle <- pathStyleFor stderr
let path' = addTrailingPathSeparator path
printStderr ("Remove " <> pathStyle path' <> " (and its children)...")
printSyncOp (RemoveFile path) = do
pathStyle <- pathStyleFor stderr
printStderr ("Remove " <> pathStyle path <> "...")
printSyncOp (CopyFile src dst) = do
pathStyle <- pathStyleFor stderr
printStderr
("Copy " <> pathStyle src <> " to " <> pathStyle dst <> "...")
printSyncOp (CreateDir path) = do
pathStyle <- pathStyleFor stderr
let path' = addTrailingPathSeparator path
printStderr ("Create " <> pathStyle path' <> "...")
printSyncOp (CreateDirs path) = do
pathStyle <- pathStyleFor stderr
let path' = addTrailingPathSeparator path
printStderr ("Create " <> pathStyle path' <> " (and its ancestors)...")
doSyncOp :: (MonadFileSystem i) => SyncOp -> i ()
doSyncOp (RemoveDirs path) = removeDirectoryRecursively path
doSyncOp (RemoveFile path) = removeFile path
doSyncOp (CopyFile src dst) = copyFile src dst
doSyncOp (CreateDir path) = createDirectory path
doSyncOp (CreateDirs path) = createDirectories path
syncSourceToIntermediate
:: [(FileEntry, FileEntry, FileDeltaKind)]
-> [SyncOp]
syncSourceToIntermediate files =
concat
[ case delta of
Unchanged -> []
Removed ->
if to.stat == Directory
then [RemoveDirs to.path]
else [RemoveFile to.path]
Modified ->
case (from.stat, to.stat) of
(Directory, _) -> [RemoveFile to.path, CreateDir to.path]
(_, Directory) -> [RemoveDirs to.path, CopyFile from.path to.path]
_ -> [CopyFile from.path to.path]
Added ->
if from.stat == Directory
then [CreateDirs to.path]
else [CreateDirs $ takeDirectory to.path, CopyFile from.path to.path]
| (from, to, delta) <- files
]
syncIntermediateToDestination
:: (MonadFileSystem i, MonadIO i)
=> Context i
-> [(FileEntry, FileEntry, FileDeltaKind)]
-> i [SyncOp]
syncIntermediateToDestination ctx files =
(fmap concat . sequence)
[ case delta of
Unchanged -> return []
Removed ->
if from.stat == Directory
then return [CreateDirs to.path]
else return [CreateDirs $ takeDirectory to.path, CopyFile from.path to.path]
Modified ->
case (from.stat, to.stat) of
(Directory, _) -> return [RemoveFile to.path, CreateDir to.path]
(_, Directory) -> return [RemoveDirs to.path, CopyFile from.path to.path]
_ -> return [CopyFile from.path to.path]
Added -> do
(state, _) <- getRouteState ctx to.path
case state of
Ignored _ _ -> return []
_ ->
if to.stat == Directory
then return [RemoveDirs to.path]
else return [RemoveFile to.path]
| (from, to, delta) <- files
]