seihou-core-0.10.0.0: src/Seihou/Engine/Checkout.hs
module Seihou.Engine.Checkout
( observeCheckoutOutputs,
prepareCheckoutPlan,
prepareCheckoutApplication,
applyCheckoutPlan,
applyCheckoutPlanWithHook,
rollbackCheckout,
CheckoutExecution (..),
CheckoutUndo (..),
checkoutGitRoot,
safeCheckoutManifest,
)
where
import Control.Exception (IOException)
import Control.Monad (foldM, forM, forM_, unless, when)
import Data.Generics.Labels ()
import Data.List (sortOn)
import Data.Map.Strict qualified as Map
import Data.Maybe (maybeToList)
import Data.Set qualified as Set
import Data.Text qualified as T
import Effectful.Exception qualified as E
import Seihou.Core.Checkout
import Seihou.Core.Types
import Seihou.Effect.Filesystem
import Seihou.Effect.Process
import Seihou.Prelude
import System.Exit (ExitCode (..))
import System.FilePath.Posix qualified as P
checkoutGitRoot :: (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Eff es (Maybe FilePath)
checkoutGitRoot root = do
result <- E.try @IOException (runProcess "git" ["rev-parse", "--show-toplevel"] (Just root))
case result of
Right (ExitSuccess, path, _) -> Just <$> normalizePath (T.unpack (T.stripEnd path))
_ -> pure Nothing
-- | Inspect metadata before opening it; a manifest must be a real file.
safeCheckoutManifest :: (Filesystem :> es) => FilePath -> Eff es (Either CheckoutError ())
safeCheckoutManifest root = do
dir <- inspectPath (root </> ".seihou")
file <- if dir == EntryDirectory then inspectPath (root </> ".seihou/manifest.json") else pure EntryMissing
pure $
if dir == EntryDirectory && file == EntryFile
then Right ()
else Left (checkoutError "checkout_manifest_unsafe" "require a real .seihou directory and manifest.json file; run seihou run to record declarations, or move symlinked metadata aside")
ancestors :: FilePath -> [FilePath]
ancestors path = reverse (go (P.takeDirectory path))
where
go "." = []; go "" = []; go p = p : go (P.takeDirectory p)
-- | Observe entries themselves, never the targets of destination symlinks.
observeCheckoutOutputs :: (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Eff es (Map FilePath CheckoutObservation)
observeCheckoutOutputs root prior candidate = do
gitRoot <- checkoutGitRoot root
indexed <- case gitRoot of
Nothing -> pure (Right [])
Just _ -> do
(code, out, _) <- runProcess "git" ["ls-files", "--cached", "-z"] (Just root)
pure $ if code == ExitSuccess then Right (map T.unpack (filter (not . T.null) (T.splitOn "\0" out))) else Left (checkoutError "checkout_git_unavailable" "cannot inspect Git's index")
metadataAlias <- inspectPath (root </> ".GIT")
let nativeOverlap a b = pathsOverlap a b || (metadataAlias /= EntryMissing && pathsOverlap (T.unpack (T.toCaseFold (T.pack a))) (T.unpack (T.toCaseFold (T.pack b))))
pairs <- forM allPaths $ \dest -> do
let active = Map.lookup dest candidate
record = case active of Just r -> r; Nothing -> prior Map.! dest
source = checkoutSource (record ^. #spec)
result <- E.try @IOException $ do
parentProblems <- (<>) <$> checkAncestors (ancestors dest) <*> (if active == Nothing then pure [] else checkAncestors (ancestors source))
entry <- if null parentProblems then inspectPath (root </> dest) else pure EntryMissing
sourceEntry <- if active == Nothing || not (null parentProblems) then pure EntryMissing else inspectPath (root </> source)
sourceAlias <- if active == Nothing || not (null parentProblems) then pure [] else checkSpelling source
destAlias <- if null parentProblems then checkSpelling dest else pure []
(ignored, ignoreProblems) <-
if gitRoot == Nothing
then pure (False, [])
else do
(code, _, _) <- runProcess "git" ["check-ignore", "-q", "--", "./" <> T.pack dest] (Just root)
pure $ case code of
ExitSuccess -> (True, [])
ExitFailure 1 -> (False, [])
_ -> (False, [checkoutError "checkout_git_unavailable" "cannot inspect ignore rules; check Git configuration"])
let trackedProblems = case indexed of
Left err -> [err]
Right paths -> [checkoutError "checkout_path_tracked" "destination overlaps a tracked path (including an indexed ancestor); change the declaration or untrack it explicitly" | any (nativeOverlap dest) paths]
aliasClaims =
[ checkoutError "checkout_native_alias" "checkout declarations have ambiguous case spellings; use distinct portable destinations"
| any (\other -> other /= dest && T.toCaseFold (T.pack other) == T.toCaseFold (T.pack dest)) allPaths
]
pure (CheckoutObservation entry (sourceEntry == EntryDirectory) (parentProblems <> trackedProblems <> ignoreProblems <> sourceAlias <> destAlias <> aliasClaims <> [checkoutError "checkout_source_not_directory" "source is not a real directory; move the object aside" | active /= Nothing, sourceEntry `notElem` [EntryMissing, EntryDirectory]]) (gitRoot /= Nothing) ignored)
pure
( dest,
case result of
Right obs -> obs
Left err -> CheckoutObservation EntryMissing False [checkoutError "checkout_path_inaccessible" ("cannot inspect '" <> T.pack dest <> "': " <> T.pack (show err))] (gitRoot /= Nothing) False
)
pure (Map.fromList pairs)
where
allPaths = Set.toAscList (Map.keysSet prior <> Map.keysSet candidate)
checkAncestors [] = pure []
checkAncestors (path : rest) =
inspectPath (root </> path) >>= \case
EntrySymlink _ -> pure [checkoutError "checkout_parent_symlink" ("ancestor '" <> T.pack path <> "' is a symlink; replace it with a real directory")]
EntryFile -> pure [checkoutError "checkout_parent_not_directory" ("ancestor '" <> T.pack path <> "' is a file; move it aside")]
EntryMissing -> pure []
EntryDirectory -> checkAncestors rest
-- Native path lookup may accept a different spelling on case-insensitive volumes.
checkSpelling path = fmap concat $ forM (ancestors path <> [path]) $ \part -> do
parent <- inspectPath (root </> P.takeDirectory part)
if parent /= EntryDirectory
then pure []
else do
names <- listDirectory (root </> P.takeDirectory part)
exists <- inspectPath (root </> part)
pure [checkoutError "checkout_native_alias" ("path '" <> T.pack part <> "' resolves through a different native spelling") | exists /= EntryMissing, P.takeFileName part `notElem` names]
prepareCheckoutPlan :: (Filesystem :> es, Process :> es, IOE :> es) => CheckoutIntent -> CheckoutPolicy -> FilePath -> Manifest -> Manifest -> [FilePath] -> Eff es (Either CheckoutError CheckoutPlan)
prepareCheckoutPlan intent policy root prior candidate pending = case validateCheckoutRecords candidate pending of
Left err -> pure (Left err)
Right () -> do
observations <- observeCheckoutOutputs root (prior ^. #checkoutOutputs) (candidate ^. #checkoutOutputs)
pure (Right (planCheckoutOutputs intent policy (prior ^. #checkoutOutputs) (candidate ^. #checkoutOutputs) observations))
-- | An undo entry describes only this attempt's own writes.
data CheckoutUndo = CheckoutUndo
{dest :: !FilePath, before :: !PathEntry, after :: !PathEntry, parents :: ![FilePath]}
deriving stock (Eq, Show, Generic)
data CheckoutExecution = CheckoutExecution
{outcomes :: !(Map FilePath Text), undo :: ![CheckoutUndo], failure :: !(Maybe CheckoutError)}
deriving stock (Eq, Show, Generic)
rollbackCheckout :: (Filesystem :> es, IOE :> es) => FilePath -> [CheckoutUndo] -> Eff es (Map FilePath Text)
rollbackCheckout root writes =
Map.fromList
<$> forM
writes
( \write -> do
result <- E.try @IOException $ do
let path = root </> (write ^. #dest)
parentsSafe <- and <$> mapM (\p -> (== EntryDirectory) <$> inspectPath (root </> p)) (ancestors (write ^. #dest))
current <- if parentsSafe then inspectPath path else pure EntryFile
if current /= write ^. #after
then pure "rollback-conflicted"
else do
case current of EntrySymlink _ -> removeLink path; _ -> pure ()
case write ^. #before of EntrySymlink target -> createLink target path; _ -> pure ()
forM_ (reverse (write ^. #parents)) (\p -> inspectPath (root </> p) >>= \kind -> when (kind == EntryDirectory) (removeDirectoryIfEmpty (root </> p)))
pure "rolled-back"
pure (write ^. #dest, either (const "rollback-conflicted") id result)
)
-- | Recheck immediately before mutation. Supports a single cooperating writer;
-- portable path checks do not protect against hostile concurrent substitution.
applyCheckoutPlan :: (Filesystem :> es, Process :> es, IOE :> es) => CheckoutPolicy -> FilePath -> CheckoutPlan -> Eff es CheckoutExecution
applyCheckoutPlan = applyCheckoutPlanWithHook (const (pure ()))
-- | Failure injection after each completed link mutation, for rollback contract tests.
applyCheckoutPlanWithHook :: (Filesystem :> es, Process :> es, IOE :> es) => (Int -> Eff es ()) -> CheckoutPolicy -> FilePath -> CheckoutPlan -> Eff es CheckoutExecution
applyCheckoutPlanWithHook afterMutation policy root plan
| not (checkoutApplicable plan) = pure (CheckoutExecution Map.empty [] (Just (checkoutError "checkout_preflight_refused" "checkout preflight refused; no outputs were changed")))
| otherwise = go Map.empty [] (plan ^. #entries)
where
go outcomes writes [] = pure (CheckoutExecution outcomes writes Nothing)
go outcomes writes (entry : rest)
| entry ^. #action `notElem` [CreateLink, ReplaceLink, RetireLink] = go (Map.insert (entry ^. #dest) "unchanged" outcomes) writes rest
| otherwise = do
let dest = entry ^. #dest
toMap spec = Map.singleton dest (CheckoutOutputRecord spec Map.empty)
old = maybe Map.empty toMap (entry ^. #previous)
new = maybe Map.empty toMap (entry ^. #candidate)
expected = maybe EntryMissing (EntrySymlink . expectedTarget dest) (entry ^. #candidate)
result <- E.try @IOException $ do
observations <- observeCheckoutOutputs root old new
let observed = observations Map.! dest
changed = observed ^. #entry /= entry ^. #observation . #entry
errors =
observed ^. #problems
<> [checkoutError "checkout_source_missing" "source directory disappeared" | entry ^. #candidate /= Nothing, not (observed ^. #sourceDirectory)]
<> [checkoutError "checkout_not_ignored" "destination is no longer ignored" | policy == SyncIgnoredOnly, entry ^. #candidate /= Nothing, not (observed ^. #ignored)]
<> [checkoutError "checkout_git_required" "Git evidence disappeared" | policy == SyncIgnoredOnly, not (observed ^. #gitAvailable)]
when (changed || not (null errors)) $ E.throwIO (userError ("checkout precondition changed for " <> dest))
missingParents <- filterM (\p -> (== EntryMissing) <$> inspectPath (root </> p)) (ancestors dest)
-- Record and undo even a failure after unlink or during parent creation.
let write = CheckoutUndo dest (observed ^. #entry) expected missingParents
mutate <- E.try @IOException $ do
forM_ missingParents (createDirectoryIfMissing False . (root </>))
case entry ^. #action of
CreateLink -> createLink (linkText entry) (root </> dest)
ReplaceLink -> do
removeLink (root </> dest)
createLink (linkText entry) (root </> dest) `E.onException` createLink (oldText observed) (root </> dest)
RetireLink -> removeLink (root </> dest)
_ -> pure ()
case mutate of
Left err -> do
forM_ (reverse missingParents) (\p -> inspectPath (root </> p) >>= \kind -> when (kind == EntryDirectory) (removeDirectoryIfEmpty (root </> p)))
E.throwIO err
Right () -> pure write
case result of
Right write -> do
hooked <- E.try @IOException (afterMutation (length writes + 1))
case hooked of
Right () -> go (Map.insert dest "applied" outcomes) (write : writes) rest
Left err -> do
reverted <- rollbackCheckout root (write : writes)
pure (CheckoutExecution (Map.union reverted outcomes) [] (Just (checkoutError "checkout_apply_failed" (T.pack (show err)))))
Left err -> do
reverted <- rollbackCheckout root writes
pure (CheckoutExecution (Map.insert dest "failed" (Map.union reverted outcomes)) [] (Just (checkoutError "checkout_apply_failed" ("cannot apply '" <> T.pack dest <> "': " <> T.pack (show err) <> "; check symlink permissions and retry"))))
linkText entry = case entry ^. #candidate of Just spec -> expectedTarget (entry ^. #dest) spec; Nothing -> ""
oldText observed = case observed ^. #entry of EntrySymlink target -> target; _ -> ""
filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]
filterM predicate = foldM (\acc x -> predicate x >>= \keep -> pure (if keep then acc <> [x] else acc)) []
-- | Before ordinary generation, a source directory may be scheduled to appear
-- through deterministic file output. Shell commands cannot satisfy this dependency.
prepareCheckoutApplication :: (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Manifest -> Manifest -> [Operation] -> Eff es (Either CheckoutError CheckoutPlan)
prepareCheckoutApplication root prior candidate operations = case validateCheckoutRecords candidate bytePaths of
Left err -> pure (Left err)
Right () -> do
observed <- observeCheckoutOutputs root (prior ^. #checkoutOutputs) (candidate ^. #checkoutOutputs)
let allowGenerated dest obs = case Map.lookup dest (candidate ^. #checkoutOutputs) of
Just record | any (\path -> T.pack (checkoutSource (record ^. #spec) <> "/") `T.isPrefixOf` T.pack path) bytePaths -> obs & #sourceDirectory .~ True
_ -> obs
pure (Right (planCheckoutOutputs Reconcile ApplyDeclaration (prior ^. #checkoutOutputs) (candidate ^. #checkoutOutputs) (Map.mapWithKey allowGenerated observed)))
where
bytePaths =
[ dest
| op <- operations,
dest <- case op of
WriteFileOp {dest} -> [dest]
CopyFileOp {dest} -> [dest]
PatchFileOp {dest} -> [dest]
_ -> []
]