gachi-plan-0.1.0.0: src/Gachi/Plan/Store.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | Module: Gachi.Plan.Store
-- Description: Detached Git store lifecycle, probing, status, and tracking operations
--
-- This module implements the detached Git store architecture for ecosystem plan
-- governance (~/src/PLAN.md §1.A, Phase 5, Milestone 5.1).
--
-- = Architectural Context & Invariants
--
-- Note [Plan Governance and Rollback Mechanics]
-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-- [tag:note-plan-governance-rollback]
-- Ecosystem planning and operational frontier documents (@PLAN.md@, @STATE.md@,
-- @INDEX.md@, and @docs/INDEX.md@) across @~/src/@ are version-controlled via an
-- isolated, detached Git repository (@--git-dir=<path> --work-tree=<root>@)
-- and an append-only pre-mutation snapshot ledger per ~/src/PLAN.md §1.A.
--
-- Core Invariants:
-- 1. __Non-Interference Invariant:__ Strictly avoids nested @.git@ collisions by
-- never writing @.git@ directories or metadata into child repository workspaces
-- (@~/src/gachi/.git@, @~/src/polytropos/.git@, etc.). The store is maintained
-- as a bare repository in an external directory (default @~/.plan.git@ or
-- @~/src/.plan.git@).
-- 2. __Governed Documents Invariant:__ Exclusively tracks top-level ecosystem
-- planning files and roadmap indexes, ignoring child directory workspaces
-- via explicit pathspec scoping (@-- <governed-files>@). Child project repositories
-- remain completely autonomous.
-- 3. __Hermetic Store Execution:__ All git operations are executed with explicit
-- @--git-dir@ and @--work-tree@ arguments, guaranteeing total independence
-- from ambient directory state or parent repository discovery.
-- 4. __Zero Partial Functions:__ Invariant enforcement and parsing strictly use
-- total pattern matching, eliminating any risk of runtime partial function crashes.
module Gachi.Plan.Store
( -- * Store Lifecycle
initPlanStore
, isStoreInitialized
, resolvePlanStorePath
-- * Status & Tracking Operations
, queryPlanStore
, listTrackedFiles
, trackGovernedFiles
, stageFiles
, commitStore
, getPlanStoreDiff
, governedFilesExist
-- * Low-Level Execution
, runGitInStore
, runGitRaw
) where
import Control.Exception (SomeException, catch)
import Control.Monad (filterM)
import Data.Maybe (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
, getHomeDirectory
, makeAbsolute
)
import System.Environment (lookupEnv)
import System.FilePath ((</>))
import System.Process (proc, readCreateProcessWithExitCode)
import Text.Read (readMaybe)
import Gachi.Plan.Types
( PlanError(..)
, PlanStore(..)
, PlanStoreStatus(..)
, defaultGovernedFiles
, defaultStoreName
, planGitDirEnvVar
)
-- | Resolves the target detached Git store directory.
--
-- Precedence:
-- 1. Explicitly provided 'FilePath' argument (if 'Just').
-- 2. Environment variable @PLAN_GIT_DIR@ (if set and non-empty).
-- 3. Canonical default at @~/.plan.git@ relative to the user's home directory.
resolvePlanStorePath :: Maybe FilePath -> IO FilePath
resolvePlanStorePath (Just p) = makeAbsolute p
resolvePlanStorePath Nothing = do
mEnv <- lookupEnv planGitDirEnvVar
case mEnv of
Just envPath | not (null envPath) -> makeAbsolute envPath
_ -> do
home <- getHomeDirectory
makeAbsolute (home </> defaultStoreName)
-- | Low-level executor invoking git with specified arguments.
-- Safely catches execution exceptions (such as missing git binary).
runGitRaw :: [String] -> IO (ExitCode, Text, Text)
runGitRaw args = do
let processSpec = proc "git" args
(code, stdoutStr, stderrStr) <-
catch
(readCreateProcessWithExitCode processSpec "")
(\(err :: SomeException) -> pure (ExitFailure 127, "", show err))
pure (code, T.pack stdoutStr, T.pack stderrStr)
-- | Executes a git command hermetically inside the detached store context.
-- Explicitly prepends @--git-dir=<path>@ and @--work-tree=<root>@ to all invocations.
runGitInStore :: PlanStore -> [String] -> IO (ExitCode, Text, Text)
runGitInStore store args = do
let storeArgs =
[ "--git-dir=" ++ planGitDir store
, "--work-tree=" ++ planWorkTree store
] ++ args
runGitRaw storeArgs
-- | Checks whether a directory exists and constitutes a valid bare Git repository.
isStoreInitialized :: FilePath -> IO Bool
isStoreInitialized storePath = do
absPath <- makeAbsolute storePath
exists <- doesDirectoryExist absPath
if not exists
then pure False
else do
(code, _, _) <- runGitRaw ["--git-dir=" ++ absPath, "rev-parse", "--git-dir"]
pure (code == ExitSuccess)
-- | Initializes or opens an isolated detached Git store.
--
-- If the bare git repository at the given store directory does not yet exist,
-- this function creates the directory and executes @git init --bare@.
-- Ensures that the governed work tree root directory also exists.
--
-- Returns a configured 'PlanStore' handle or a 'PlanError' upon initialization failure.
initPlanStore :: FilePath -> FilePath -> IO (Either PlanError PlanStore)
initPlanStore rawStorePath rawWorkTree = do
absStore <- makeAbsolute rawStorePath
absWork <- makeAbsolute rawWorkTree
createDirectoryIfMissing True absWork
storeExists <- doesDirectoryExist absStore
if not storeExists
then do
createDirectoryIfMissing True absStore
(code, _, stderrTxt) <- runGitRaw ["init", "--bare", absStore]
case code of
ExitSuccess -> pure (Right (PlanStore absStore absWork defaultGovernedFiles))
ExitFailure _ -> pure (Left (PlanGitError stderrTxt))
else do
alreadyInit <- isStoreInitialized absStore
if alreadyInit
then pure (Right (PlanStore absStore absWork defaultGovernedFiles))
else do
(code, _, stderrTxt) <- runGitRaw ["init", "--bare", absStore]
case code of
ExitSuccess -> pure (Right (PlanStore absStore absWork defaultGovernedFiles))
ExitFailure _ -> pure (Left (PlanGitError stderrTxt))
-- | Lists all governed files currently tracked in the detached Git store index.
-- Runs @git ls-files@ against the detached store.
listTrackedFiles :: PlanStore -> IO [FilePath]
listTrackedFiles store = do
(code, stdoutTxt, _) <- runGitInStore store ["ls-files"]
case code of
ExitSuccess ->
let nonBlank = filter (not . null) (map T.unpack (T.lines stdoutTxt))
in pure nonBlank
ExitFailure _ -> pure []
-- | Queries the status of the detached Git plan store.
--
-- Inspects:
-- 1. Current HEAD commit SHA hash (or 'Nothing' if the repository is newly initialized).
-- 2. Last commit timestamp parsed from POSIX commit seconds.
-- 3. Currently tracked files via 'listTrackedFiles'.
-- 4. Dirty tracking status exclusively across governed files using pathspec scoping.
queryPlanStore :: PlanStore -> IO PlanStoreStatus
queryPlanStore store = do
-- 1. Query current HEAD commit SHA
(revCode, revOut, _) <- runGitInStore store ["rev-parse", "--verify", "HEAD"]
let currentHeadSha =
case revCode of
ExitSuccess ->
let stripped = T.strip revOut
in if T.null stripped then Nothing else Just stripped
ExitFailure _ -> Nothing
-- 2. Query last commit timestamp (POSIX timestamp format via %ct)
commitTime <-
case currentHeadSha of
Nothing -> pure Nothing
Just _ -> do
(timeCode, timeOut, _) <- runGitInStore store ["log", "-1", "--format=%ct"]
case timeCode of
ExitSuccess ->
let s = T.unpack (T.strip timeOut)
in case (readMaybe s :: Maybe Integer) of
Just sec -> pure (Just (posixSecondsToUTCTime (fromIntegral sec)))
Nothing -> pure Nothing
ExitFailure _ -> pure Nothing
-- 3. Query tracked files
trackedList <- listTrackedFiles store
-- 4. Query dirty state restricted exclusively to governed files
let governed = planGovernedFiles store
(statusCode, statusOut, _) <-
if null governed
then runGitInStore store ["status", "--porcelain"]
else runGitInStore store (["status", "--porcelain", "--"] ++ governed)
let porcelainLines =
case statusCode of
ExitSuccess -> map T.unpack (T.lines statusOut)
ExitFailure _ -> []
-- Extract path from porcelain status output "XY <path>"
extractDirtyPath :: String -> Maybe FilePath
extractDirtyPath lineStr
| length lineStr > 3 = Just (drop 3 lineStr)
| otherwise = Nothing
dirtyPaths = mapMaybe extractDirtyPath porcelainLines
dirtyFlag = not (null dirtyPaths)
pure PlanStoreStatus
{ statusStorePath = planGitDir store
, statusWorkTree = planWorkTree store
, statusHeadCommit = currentHeadSha
, statusIsDirty = dirtyFlag
, statusLastCommitTime = commitTime
, statusDirtyFiles = dirtyPaths
, statusTrackedFiles = trackedList
}
-- | Stages specified relative file paths into the detached Git store index.
stageFiles :: PlanStore -> [FilePath] -> IO (Either PlanError ())
stageFiles _ [] = pure (Right ())
stageFiles store paths = do
(code, _, stderrTxt) <- runGitInStore store (["add", "--"] ++ paths)
case code of
ExitSuccess -> pure (Right ())
ExitFailure _ -> pure (Left (PlanGitError stderrTxt))
-- | Automatically stages all canonical governed planning files that exist on disk in the worktree.
trackGovernedFiles :: PlanStore -> IO (Either PlanError [FilePath])
trackGovernedFiles store = do
let workRoot = planWorkTree store
existingFiles <- filterM (\f -> doesFileExist (workRoot </> f)) (planGovernedFiles store)
case existingFiles of
[] -> pure (Right [])
filesToStage -> do
stageResult <- stageFiles store filesToStage
case stageResult of
Right () -> pure (Right filesToStage)
Left err -> pure (Left err)
-- | Commits staged changes to the detached Git store with a given commit message.
-- Injects hermetic user identity configuration to prevent failures in unconfigured environments.
commitStore :: PlanStore -> Text -> IO (Either PlanError Text)
commitStore store message = do
let commitArgs =
[ "-c", "user.name=Gachi Plan Governance"
, "-c", "user.email=gachi-plan@ecosystem.local"
, "commit"
, "-m", T.unpack message
]
(code, _, stderrTxt) <- runGitInStore store commitArgs
case code of
ExitSuccess -> do
(revCode, revOut, revErr) <- runGitInStore store ["rev-parse", "HEAD"]
case revCode of
ExitSuccess -> pure (Right (T.strip revOut))
ExitFailure _ -> pure (Left (PlanGitError revErr))
ExitFailure _ -> pure (Left (PlanGitError stderrTxt))
-- | Retrieves the Git diff of governed planning documents against HEAD or a specified revision.
getPlanStoreDiff :: PlanStore -> Maybe Text -> IO (Either PlanError Text)
getPlanStoreDiff store mTargetRevision = do
let baseArgs =
case mTargetRevision of
Just rev -> ["diff", T.unpack rev, "--"]
Nothing -> ["diff", "HEAD", "--"]
diffArgs = baseArgs ++ planGovernedFiles store
(code, stdoutTxt, stderrTxt) <- runGitInStore store diffArgs
case code of
ExitSuccess -> pure (Right stdoutTxt)
ExitFailure _ -> pure (Left (PlanGitError stderrTxt))
-- | Checks which governed planning files physically exist in the worktree directory on disk.
governedFilesExist :: PlanStore -> IO [(FilePath, Bool)]
governedFilesExist store = do
let root = planWorkTree store
mapM (\relPath -> do
exists <- doesFileExist (root </> relPath)
pure (relPath, exists)
) (planGovernedFiles store)