hydra-0.15.0: src/exec/digest-check/Main.hs
-- | Per-target freshness check + digest refresh tool.
--
-- Two subcommands:
--
-- digest-check fresh --inputs <file> --output-digest <file>
-- Reads the input digest (e.g. dist/json/<pkg>/digest.json) and
-- the output digest (e.g. dist/<lang>/<pkg>/digest.json).
-- Returns exit 0 if:
-- * the inputs section of the output digest matches the input
-- digest's hashes,
-- * every output file recorded in the output digest exists and
-- hashes to its recorded value,
-- * the generator stamp matches the current generator stamp.
-- Returns exit 1 (cache miss) otherwise.
--
-- Callers use this with an `if !` shell idiom to skip work when
-- fresh and run the work otherwise.
--
-- digest-check refresh --inputs <file> --output-dir <dir>
-- --output-digest <file>
-- Recomputes the hash of every regular file under <dir> (recursive),
-- reads the input digest, and writes a new output digest with both
-- sections populated plus the current generator stamp.
--
-- Run after a successful regen to record what was produced.
--
-- All paths are taken as-is (no implicit normalization).
module Main where
import Hydra.Digest
import Hydra.Packaging (Namespace(..))
import Control.Monad (when, forM)
import Data.List (isPrefixOf)
import qualified Data.Map as M
import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
import System.Environment (getArgs)
import System.Exit (exitFailure, exitSuccess)
import qualified System.FilePath as FP
import System.IO (hPutStrLn, stderr)
data Mode = Fresh | Refresh deriving (Eq, Show)
data Options = Options
{ optMode :: Mode
, optInputDigest :: FilePath
, optOutputDigest :: FilePath
, optOutputDir :: Maybe FilePath
} deriving Show
usage :: String
usage = unlines
[ "Usage:"
, " digest-check fresh --inputs <file> --output-dir <dir> --output-digest <file>"
, " digest-check refresh --inputs <file> --output-dir <dir> --output-digest <file>"
, ""
, " fresh: exit 0 if cache hit (skip work), exit 1 if miss (do work)."
, " Resolves recorded (relative) output paths against <output-dir>."
, " refresh: walk <output-dir>, hash every file, write a new"
, " <output-digest> with paths stored relative to <output-dir>."
]
parseArgs :: [String] -> Either String Options
parseArgs [] = Left "Missing subcommand"
parseArgs (cmd : rest) = do
mode <- case cmd of
"fresh" -> Right Fresh
"refresh" -> Right Refresh
_ -> Left ("Unknown subcommand: " ++ cmd)
go (Options mode "" "" Nothing) rest
where
go opts [] = if null (optInputDigest opts) || null (optOutputDigest opts)
then Left "Missing required --inputs or --output-digest"
else if optOutputDir opts == Nothing
then Left "--output-dir is required"
else Right opts
go opts ("--inputs" : v : xs) = go (opts { optInputDigest = v }) xs
go opts ("--output-digest" : v : xs) = go (opts { optOutputDigest = v }) xs
go opts ("--output-dir" : v : xs) = go (opts { optOutputDir = Just v }) xs
go _ (a : _) = Left ("Unknown argument: " ++ a)
main :: IO ()
main = do
args <- getArgs
case parseArgs args of
Left err -> do
hPutStrLn stderr ("Error: " ++ err)
hPutStrLn stderr usage
exitFailure
Right opts -> case optMode opts of
Fresh -> doFresh opts
Refresh -> doRefresh opts
doFresh :: Options -> IO ()
doFresh opts = do
-- Input digest: must exist. Absent means we have no record of what
-- was used, so always treat as cache miss.
inputExists <- doesFileExist (optInputDigest opts)
if not inputExists
then do
putStrLn $ " digest-check: input digest absent ("
++ optInputDigest opts ++ "); cache miss"
exitFailure
else return ()
-- Output digest: must exist. Absent means we have no record of
-- what was produced previously.
outputExists <- doesFileExist (optOutputDigest opts)
if not outputExists
then do
putStrLn $ " digest-check: output digest absent ("
++ optOutputDigest opts ++ "); cache miss"
exitFailure
else return ()
inputDigest <- Hydra.Digest.readDigest (optInputDigest opts)
outputDigest <- Hydra.Digest.readDigestV2 (optOutputDigest opts)
-- Compare: each input hash from the v1 input digest must appear,
-- with the same hash, in the v2 output digest's inputs map. (We
-- key by namespace string in v1; in v2 we use FilePath. The bridge
-- is namespace strings live as paths in the output digest, recorded
-- by 'refresh' below.)
let recordedInputs = M.map entryHash (digestInputs outputDigest)
currentInputs = M.fromList
[ (k, v) | (Namespace k, v) <- M.toList inputDigest ]
if recordedInputs /= currentInputs
then do
putStrLn $ " digest-check: input mismatch; cache miss"
exitFailure
else return ()
-- Generator stamp must match.
currentGen <- generatorStamp
if currentGen /= digestGenerator outputDigest
then do
putStrLn $ " digest-check: generator stamp mismatch ("
++ digestGenerator outputDigest ++ " vs " ++ currentGen
++ "); cache miss"
exitFailure
else return ()
-- Output files must all exist with matching hashes. Paths recorded
-- in the digest are relative to outputDir (per 'refresh' below).
let outputDir = case optOutputDir opts of
Just d -> d
Nothing -> error "doFresh called without output-dir (parseArgs bug)"
outputsOk <- fmap and $ forM (M.toList (digestOutputs outputDigest)) $ \(rel, entry) -> do
let abs_ = outputDir FP.</> rel
exists <- doesFileExist abs_
if not exists then return False else do
h <- Hydra.Digest.hashFile abs_
return (h == entryHash entry)
if not outputsOk
then do
putStrLn $ " digest-check: output files missing or modified; cache miss"
exitFailure
else do
putStrLn $ " digest-check: cache hit; skipping work"
exitSuccess
doRefresh :: Options -> IO ()
doRefresh opts = do
let outputDir = case optOutputDir opts of
Just d -> d
Nothing -> error "doRefresh called without output-dir (parseArgs bug)"
-- Read the input digest. May be absent if the inputs themselves
-- aren't cached yet; that's OK, we'll just record an empty inputs
-- map and the next 'fresh' check will miss until inputs settle.
inputDigest <- Hydra.Digest.readDigest (optInputDigest opts)
let inputsAsMap = M.fromList
[ (k, DigestEntry KindOther v)
| (Namespace k, v) <- M.toList inputDigest
]
-- Walk the output dir. Paths are stored relative to outputDir for
-- portability (absolute paths would bake in the worktree location
-- and break across machines, CI runners, etc).
--
-- Exclude the output-digest file itself from the walk: we're about
-- to overwrite it, and hashing it here would (a) be a self-reference
-- that changes every run and (b) race with writeDigestV2 below.
allFiles <- listFilesRecursive outputDir
-- Normalize both sides so "dir//digest.json" (double slash from a
-- pkg_dir with trailing /) compares equal to "dir/digest.json" as
-- emitted by listFilesRecursive.
let digestPath = FP.normalise (optOutputDigest opts)
files = filter (\fp -> FP.normalise fp /= digestPath) allFiles
outputs <- fmap M.fromList $ forM files $ \fp -> do
h <- Hydra.Digest.hashFile fp
let rel = makeRelative' outputDir fp
return (rel, DigestEntry KindTargetFile h)
gen <- generatorStamp
let d = Digest
{ digestInputs = inputsAsMap
, digestOutputs = outputs
, digestGenerator = gen
}
Hydra.Digest.writeDigestV2 (optOutputDigest opts) d
putStrLn $ " digest-check: wrote " ++ optOutputDigest opts
++ " (" ++ show (M.size inputsAsMap) ++ " inputs, "
++ show (M.size outputs) ++ " outputs)"
-- | Recursively list every regular file under a directory.
-- Skips dotfiles and dot-directories.
listFilesRecursive :: FilePath -> IO [FilePath]
listFilesRecursive root = do
exists <- doesDirectoryExist root
if not exists then return [] else go root
where
go dir = do
entries <- listDirectory dir
fmap concat $ forM entries $ \e ->
if "." `isPrefixOf` e
then return []
else do
let p = dir FP.</> e
isDir <- doesDirectoryExist p
if isDir
then go p
else do
isFile <- doesFileExist p
return (if isFile then [p] else [])
-- | Compute 'path' relative to 'base'. If 'path' isn't under 'base',
-- returns 'path' unchanged (callers should guard against that, but the
-- fallback keeps us from producing absolute paths accidentally).
makeRelative' :: FilePath -> FilePath -> FilePath
makeRelative' base path =
let prefix = if not (null base) && last base == '/' then base else base ++ "/"
in if prefix `isPrefixOf` path
then drop (length prefix) path
else path