packages feed

gachi-plan-0.1.0.0: src/Gachi/Plan/Rollback.hs

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module: Gachi.Plan.Rollback
-- Description: Atomic rollback engine, pre-mutation safety snapshots, and differential history
--
-- This module implements Milestone 5.3 of Phase 5 (Ecosystem Plan Governance & Rollback
-- per ~/src/PLAN.md §1.A). It delivers atomic rollback mechanics, pre-mutation safety
-- snapshots, unified diff computation, structured git log queries, and checkpoint commits.
--
-- = Architectural Context & Invariants
--
-- Note [Deterministic Plan Rollback Invariants]
-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-- [tag:note-plan-rollback-invariants]
-- The plan rollback engine guarantees deterministic, reversible state transitions
-- across ecosystem planning and governance documents (PLAN.md, STATE.md, INDEX.md,
-- and docs/INDEX.md).
--
-- Core Invariants:
-- 1. __Pre-Mutation Safety Invariant:__
--    Prior to executing any destructive mutation or checkout of working tree files,
--    the rollback engine MUST record immutable pre-mutation safety snapshots of all
--    governed files currently on disk in the snapshot ledger (~/src/plans/snapshots/).
--    This ensures that uncommitted progress or interim state is never permanently lost.
--
-- 2. __Hermetic Object Store Restoration:__
--    Restoration checks out objects exclusively from the detached Git store
--    using explicit tree pathspecs (@git checkout <ref> -- <governed-files>@).
--    No ambient child repository checkouts or working files outside the governed
--    set are inspected or modified.
--
-- 3. __Byte-Level Verification Invariant:__
--    Every restored file is verified post-checkout by comparing its raw disk bytes
--    against the cryptographic blob stored in the Git object database at the target
--    revision (@git cat-file -p <ref>:<file>@). Any mismatch immediately reports
--    an integrity verification failure.
--
-- 4. __Audit Trail & Attribution Preservation:__
--    Checkpoints recorded during plan governance preserve Sarutahiko RFC 2822
--    attribution trailers (@Assisted-by:@, @Orchestrated-by:@). Rollback queries
--    expose structured log history and semantic diffs without losing provenance.
--
-- 5. __Zero Partial Functions Invariant:__
--    All parsing, string splitting, path manipulation, and tree queries are
--    strictly total. No partial functions (such as unsafe indexing or extraction)
--    are permitted in the rollback or CLI governance pipelines.
module Gachi.Plan.Rollback
  ( -- * Atomic Rollback & Verification
    rollbackToCheckpoint
  , verifyFilesIntegrity
  , verifySingleFile

    -- * Differential Analysis & Log Queries
  , computePlanDiff
  , queryPlanLog

    -- * Checkpoint Creation & Attribution
  , createPlanCheckpoint
  , formatSarutahikoCommitMessage
  , extractTrailers

    -- * Path & Snapshot Resolution
  , resolveSnapshotDirForStore
  ) where

import Control.Exception (SomeException, catch)
import Control.Monad (filterM)
import qualified Data.ByteString as BS
import Data.Char (isAsciiLower, isAsciiUpper)
import Data.Foldable (for_)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import GHC.IO.Exception (ExitCode(..))
import System.Directory
  ( createDirectoryIfMissing
  , doesDirectoryExist
  , doesFileExist
  , makeAbsolute
  )
import System.Environment (lookupEnv)
import System.FilePath ((</>))
import System.IO (hClose, hGetContents)
import System.Process
  ( CreateProcess(..)
  , StdStream(..)
  , createProcess
  , proc
  , waitForProcess
  )
import Text.Read (readMaybe)

import Gachi.Plan.Snapshot
  ( createSnapshot
  , resolvePlanSnapshotDir
  )
import Gachi.Plan.Store
  ( commitStore
  , runGitInStore
  , trackGovernedFiles
  )
import Gachi.Plan.Types
  ( CheckpointResult(..)
  , PlanCommit(..)
  , PlanDiff(..)
  , PlanError(..)
  , PlanStore(..)
  , RollbackResult(..)
  , SnapshotMeta(..)
  , defaultSnapshotDirName
  , planSnapshotDirEnvVar
  )

-- | Resolves the snapshot ledger directory for a given 'PlanStore'.
--
-- Precedence:
-- 1. Environment variable @PLAN_SNAPSHOT_DIR@ (if set and non-empty).
-- 2. @plans/snapshots@ relative to 'planWorkTree' (if exists or if @plans@ dir exists).
-- 3. Canonical default snapshot directory via 'resolvePlanSnapshotDir'.
resolveSnapshotDirForStore :: PlanStore -> IO FilePath
resolveSnapshotDirForStore store = do
  mEnv <- lookupEnv planSnapshotDirEnvVar
  case mEnv of
    Just envPath | not (null envPath) -> makeAbsolute envPath
    _ -> do
      let workSnapDir = planWorkTree store </> defaultSnapshotDirName
      workSnapExists <- doesDirectoryExist workSnapDir
      if workSnapExists
        then makeAbsolute workSnapDir
        else do
          let workPlansDir = planWorkTree store </> "plans"
          workPlansExists <- doesDirectoryExist workPlansDir
          if workPlansExists
            then makeAbsolute workSnapDir
            else resolvePlanSnapshotDir Nothing

-- | Executes low-level git invocation hermetically inside detached store, capturing raw bytes.
runGitInStoreBytes :: PlanStore -> [String] -> IO (ExitCode, BS.ByteString, Text)
runGitInStoreBytes store args = do
  let storeArgs =
        [ "--git-dir=" ++ planGitDir store
        , "--work-tree=" ++ planWorkTree store
        ] ++ args
  let p = (proc "git" storeArgs)
        { std_out = CreatePipe
        , std_err = CreatePipe
        }
  catch
    (do (mIn, mOut, mErr, ph) <- createProcess p
        for_ mIn hClose
        outBytes <- case mOut of
          Just outh -> BS.hGetContents outh
          Nothing   -> pure BS.empty
        errStr <- case mErr of
          Just errh -> hGetContents errh
          Nothing   -> pure ""
        exitCode <- waitForProcess ph
        pure (exitCode, outBytes, T.pack errStr)
    )
    (\(err :: SomeException) -> pure (ExitFailure 127, BS.empty, T.pack (show err)))

-- | Verifies a single restored file on disk against the raw blob stored in the detached Git store.
verifySingleFile :: PlanStore -> Text -> FilePath -> IO (Either PlanError Bool)
verifySingleFile store commitSha relPath = do
  let fullPath = planWorkTree store </> relPath
  diskExists <- doesFileExist fullPath
  if not diskExists
    then pure (Right False)
    else do
      diskBytes <- BS.readFile fullPath
      let gitObjSpec = T.unpack commitSha ++ ":" ++ relPath
      (code, blobBytes, _) <- runGitInStoreBytes store ["cat-file", "-p", gitObjSpec]
      case code of
        ExitSuccess   -> pure (Right (diskBytes == blobBytes))
        ExitFailure _ -> pure (Right False)

-- | Verifies bitwise integrity for a collection of restored files against the target commit.
verifyFilesIntegrity :: PlanStore -> Text -> [FilePath] -> IO (Either PlanError Bool)
verifyFilesIntegrity _ _ [] = pure (Right True)
verifyFilesIntegrity store commitSha files = do
  results <- mapM (verifySingleFile store commitSha) files
  case sequence results of
    Left err    -> pure (Left err)
    Right bools -> pure (Right (and bools))

-- | Deterministically rolls back ecosystem planning state to a specified checkpoint reference.
--
-- In accordance with Note [Deterministic Plan Rollback Invariants]
-- [ref:note-plan-rollback-invariants]:
-- 1. Validates that the target revision exists in the detached Git repository.
-- 2. Records pre-mutation safety snapshots of all governed files currently existing on disk.
-- 3. Restores governed files from the Git object store (@git checkout <ref> -- <files>@).
-- 4. Verifies byte-level integrity by comparing restored disk contents with Git blob objects.
rollbackToCheckpoint :: PlanStore -> Text -> IO (Either PlanError RollbackResult)
rollbackToCheckpoint store targetRef = do
  -- 1. Validate target ref in store
  (revCode, revOut, revErr) <- runGitInStore store ["rev-parse", "--verify", T.unpack targetRef ++ "^{commit}"]
  case revCode of
    ExitFailure _ -> pure (Left (PlanGitError ("Target ref does not exist: " <> targetRef <> " (" <> revErr <> ")")))
    ExitSuccess -> do
      let resolvedSha = T.strip revOut

      -- 2. Identify governed files present in target revision
      (treeCode, treeOut, _) <- runGitInStore store ["ls-tree", "-r", "--name-only", T.unpack resolvedSha]
      let filesInCommit =
            case treeCode of
              ExitSuccess ->
                let treeLines = map T.unpack (T.lines treeOut)
                in filter (`elem` planGovernedFiles store) treeLines
              ExitFailure _ -> []
          filesToCheckout =
            if null filesInCommit
              then planGovernedFiles store
              else filesInCommit

      -- 3. Take pre-mutation safety snapshots of all existing governed files on disk
      snapDir <- resolveSnapshotDirForStore store
      createDirectoryIfMissing True snapDir
      let workRoot = planWorkTree store
      existingWorkFiles <- filterM (\f -> doesFileExist (workRoot </> f)) (planGovernedFiles store)
      safetySnapsEither <- mapM (\f -> createSnapshot snapDir (workRoot </> f) False) existingWorkFiles
      case sequence safetySnapsEither of
        Left err -> pure (Left err)
        Right safetySnaps -> do
          -- 4. Restore target files via git checkout
          (checkoutCode, _, checkoutErr) <-
            runGitInStore store (["checkout", T.unpack resolvedSha, "--"] ++ filesToCheckout)
          case checkoutCode of
            ExitFailure _ -> pure (Left (PlanGitError ("Git checkout failed: " <> checkoutErr)))
            ExitSuccess -> do
              -- 5. Verify byte-level integrity
              verifyRes <- verifyFilesIntegrity store resolvedSha filesToCheckout
              case verifyRes of
                Left err -> pure (Left err)
                Right verified ->
                  pure (Right RollbackResult
                    { rollbackTargetRef       = resolvedSha
                    , rollbackSafetySnapshots = safetySnaps
                    , rollbackRestoredFiles   = filesToCheckout
                    , rollbackVerified        = verified
                    })

-- | Computes unified diff of governed documents between working tree and a Git revision.
computePlanDiff :: PlanStore -> Maybe Text -> IO (Either PlanError PlanDiff)
computePlanDiff store mTargetRevision = do
  let baseDiffArgs =
        case mTargetRevision of
          Just rev -> ["diff", T.unpack rev, "--"]
          Nothing  -> ["diff", "HEAD", "--"]
      diffArgs = baseDiffArgs ++ planGovernedFiles store
  (code, stdoutTxt, stderrTxt) <- runGitInStore store diffArgs
  case code of
    ExitFailure _ -> pure (Left (PlanGitError stderrTxt))
    ExitSuccess -> do
      let nameArgs =
            (case mTargetRevision of
               Just rev -> ["diff", "--name-only", T.unpack rev, "--"]
               Nothing  -> ["diff", "--name-only", "HEAD", "--"])
            ++ planGovernedFiles store
      (nameCode, nameOut, _) <- runGitInStore store nameArgs
      let changed =
            case nameCode of
              ExitSuccess   -> filter (not . null) (map T.unpack (T.lines nameOut))
              ExitFailure _ -> []

          diffLines = T.lines stdoutTxt
          isAdd l = "+" `T.isPrefixOf` l && not ("+++" `T.isPrefixOf` l)
          isDel l = "-" `T.isPrefixOf` l && not ("---" `T.isPrefixOf` l)
          addCount = length (filter isAdd diffLines)
          delCount = length (filter isDel diffLines)

      pure (Right PlanDiff
        { diffTargetRef    = mTargetRevision
        , diffRawContent   = stdoutTxt
        , diffChangedFiles = changed
        , diffAdditions    = addCount
        , diffDeletions    = delCount
        })

-- | Parses a single commit record emitted with delimiter bytes.
parseCommitRecord :: Text -> PlanCommit
parseCommitRecord rawRecord =
  let fields = T.splitOn "\x1f" rawRecord
      getFld idx = case drop idx fields of
        (val:_) -> val
        []      -> ""
      cHash      = T.strip (getFld 0)
      cShortHash = T.strip (getFld 1)
      cAuthor    = T.strip (getFld 2)
      cTimeSec   = T.strip (getFld 3)
      cSubject   = T.strip (getFld 4)
      cBody      = T.strip (getFld 5)
      cTimestamp =
        case (readMaybe (T.unpack cTimeSec) :: Maybe Integer) of
          Just s  -> posixSecondsToUTCTime (fromIntegral s)
          Nothing -> posixSecondsToUTCTime 0
      trailers   = extractTrailers cBody
  in PlanCommit
       { commitHash      = cHash
       , commitShortHash = cShortHash
       , commitAuthor    = cAuthor
       , commitTimestamp = cTimestamp
       , commitSubject   = cSubject
       , commitBody      = cBody
       , commitTrailers  = trailers
       }

-- | Extracts RFC 2822 git trailers from a commit body.
extractTrailers :: Text -> [(Text, Text)]
extractTrailers bodyText =
  let bodyLines = map T.strip (T.lines bodyText)
      parseTrailerLine line =
        case T.breakOn ":" line of
          (k, rest)
            | not (T.null rest) && isTrailerKey k ->
                let val = T.strip (T.drop 1 rest)
                in Just (k, val)
          _ -> Nothing
      isTrailerKey k =
        not (T.null k) && T.all (\c -> c == '-' || isAsciiLower c || isAsciiUpper c) k
  in mapMaybe parseTrailerLine bodyLines

-- | Queries the chronological commit log of plan governance history.
queryPlanLog :: PlanStore -> Int -> IO (Either PlanError [PlanCommit])
queryPlanLog store limit = do
  let n = if limit <= 0 then 20 else limit
      logFormat = "--format=%H%x1f%h%x1f%an <%ae>%x1f%ct%x1f%s%x1f%b%x1e"
      args = ["log", "-n", show n, logFormat, "--"] ++ planGovernedFiles store
  (code, stdoutTxt, stderrTxt) <- runGitInStore store args
  case code of
    ExitFailure _ ->
      if "does not have any commits yet" `T.isInfixOf` stderrTxt
         || "fatal: your current branch 'master' does not have any commits yet" `T.isInfixOf` stderrTxt
         || "fatal: your current branch 'main' does not have any commits yet" `T.isInfixOf` stderrTxt
        then pure (Right [])
        else pure (Left (PlanGitError stderrTxt))
    ExitSuccess -> do
      let rawRecords = T.splitOn "\x1e" stdoutTxt
          nonEmptyRecords = filter (not . T.null . T.strip) rawRecords
          parsedCommits = map parseCommitRecord nonEmptyRecords
      pure (Right parsedCommits)

-- | Formats a commit message with canonical Sarutahiko RFC 2822 commit attribution trailers.
formatSarutahikoCommitMessage :: Text -> Text
formatSarutahikoCommitMessage rawMsg =
  let msg = T.strip rawMsg
      effectiveMsg = if T.null msg then "Ecosystem plan governance checkpoint" else msg
      hasAssisted = "Assisted-by:" `T.isInfixOf` effectiveMsg
      hasOrchestrated = "Orchestrated-by:" `T.isInfixOf` effectiveMsg
      trailers = case (hasAssisted, hasOrchestrated) of
        (True, True)   -> ""
        (True, False)  -> "\nOrchestrated-by: Sarutahiko"
        (False, True)  -> "\nAssisted-by: Antigravity"
        (False, False) -> "\nAssisted-by: Antigravity\nOrchestrated-by: Sarutahiko"
  in if T.null trailers
       then effectiveMsg
       else effectiveMsg <> "\n" <> trailers

-- | Creates an atomic plan milestone checkpoint.
--
-- 1. Takes pre-mutation snapshots marked as milestone checkpoints for all existing governed files.
-- 2. Stages all existing governed files in the detached Git store.
-- 3. Atomically commits them with Sarutahiko RFC 2822 commit attribution trailers.
createPlanCheckpoint :: PlanStore -> Maybe Text -> IO (Either PlanError CheckpointResult)
createPlanCheckpoint store mUserMsg = do
  let workRoot = planWorkTree store
  existingFiles <- filterM (\f -> doesFileExist (workRoot </> f)) (planGovernedFiles store)
  if null existingFiles
    then pure (Left (PlanIOError "No governed files found on disk to checkpoint"))
    else do
      snapDir <- resolveSnapshotDirForStore store
      createDirectoryIfMissing True snapDir
      snapsEither <- mapM (\f -> createSnapshot snapDir (workRoot </> f) True) existingFiles
      case sequence snapsEither of
        Left err -> pure (Left err)
        Right snapshots -> do
          stageRes <- trackGovernedFiles store
          case stageRes of
            Left err -> pure (Left err)
            Right stagedPaths -> do
              let rawMsg = fromMaybe "Ecosystem plan governance checkpoint" mUserMsg
                  formattedMsg = formatSarutahikoCommitMessage rawMsg
              commitRes <- commitStore store formattedMsg
              case commitRes of
                Left err -> pure (Left err)
                Right commitSha ->
                  pure (Right CheckpointResult
                    { checkpointCommitSha   = commitSha
                    , checkpointSnapshots   = snapshots
                    , checkpointStagedFiles = stagedPaths
                    , checkpointMessage     = formattedMsg
                    })