packages feed

salmon-ops-0.1.0.0: src/Salmon/Actions/Follow/Registry/Git.hs

{-# LANGUAGE OverloadedStrings #-}

{- | The git registry for "Salmon.Actions.Follow": the desired state is a
repository, and the document for a label is a file in it.

@--follow git+\<url\>[#\<branch\>[:\<subdir\>]]@ names it. The repository is
cloned once into a working directory the caller chooses (see
"Salmon.Actions.Follow.Registry" for the default), and every round is a
@git fetch@ followed by a @git reset --hard@ onto what the remote branch
now points at — never a merge, since the checkout is nobody's to edit. The
document for label @L@ is @\<subdir\>/\<L\>.json@ in that checkout, and the
stamp is the commit the branch resolved to: a round that finds the same
commit reads nothing, which is the directory registry's "ask before
reading" with a hash instead of an mtime. A file missing from the commit is
'Absent'; the fetch failing — no network, no such branch, a credential
prompt refused (@GIT_TERMINAL_PROMPT@ is off, so a private repository fails
rather than hangs) — throws and is a failed round.

Everything goes through the @git@ binary as "Salmon.Builtin.Nodes.Git"
does, with 'Binary.untrackedExec' so that a non-zero exit is a throw
carrying git's own stderr, and not a Haskell git library.

The subdirectory has to come after the branch (@#main:hosts@, or @#:hosts@
for the remote's default branch), because a URL has colons of its own —
@ssh://host:22/repo@, @git\@host:repo.git@ — and the spec's
@[#\<branch\>][:\<subdir\>]@ leaves which one is the subdirectory's to
guess.
-}
module Salmon.Actions.Follow.Registry.Git (
    Source (..),
    parseSource,
    renderSource,
    gitRegistry,
    documentPathIn,
) where

import qualified Data.ByteString.Char8 as C8
import qualified Data.ByteString.Lazy as LByteString
import Data.Text (Text)
import qualified Data.Text as Text
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
import System.Environment (getEnvironment)
import System.FilePath ((<.>), (</>))
import System.Process.ListLike (CreateProcess (..), proc)

import Salmon.Actions.Follow (Fetch (..), Label, Registry (..), Stamp (..), digestOf, labelText)
import Salmon.Builtin.Nodes.Binary (Command (..))
import qualified Salmon.Builtin.Nodes.Binary as Binary
import Salmon.Reporter (silent)

-- | A repository, a branch (the remote's default when 'Nothing') and the
-- subdirectory the documents are under (the root when 'Nothing').
data Source = Source
    { sourceUrl :: Text
    , sourceBranch :: Maybe Text
    , sourceSubdir :: Maybe FilePath
    }
    deriving (Show, Eq)

-- | What follows @git+@: @\<url\>[#\<branch\>[:\<subdir\>]]@.
parseSource :: Text -> Either Text Source
parseSource spec
    | Text.null url = Left ("a git registry needs a URL: git+" <> spec)
    | otherwise = case Text.stripPrefix "#" fragment of
        Nothing -> Right (Source url Nothing Nothing)
        Just rest ->
            let (branch, subdir) = Text.breakOn ":" rest
             in Right
                    ( Source
                        url
                        (if Text.null branch then Nothing else Just branch)
                        (case Text.unpack (Text.drop 1 subdir) of "" -> Nothing; d -> Just d)
                    )
  where
    (url, fragment) = Text.breakOn "#" spec

-- | The address back, as @git+...@: what @history@ and reports name.
renderSource :: Source -> Text
renderSource s =
    "git+"
        <> s.sourceUrl
        <> case (s.sourceBranch, s.sourceSubdir) of
            (Nothing, Nothing) -> ""
            (b, d) -> "#" <> maybe "" id b <> maybe "" (\d' -> ":" <> Text.pack d') d

-- | Where a label's document is in a checkout: @\<subdir\>/\<label\>.json@.
documentPathIn :: FilePath -> Source -> Label -> FilePath
documentPathIn workdir s lbl = maybe workdir (workdir </>) s.sourceSubdir </> Text.unpack (labelText lbl) <.> "json"

{- | A registry over a checkout at @workdir@, made once per process (the
environment is read here, once, to turn off git's credential prompts). -}
gitRegistry :: FilePath -> Source -> IO Registry
gitRegistry workdir source = do
    env <- getEnvironment
    let git = Command (\args -> (proc "git" args){env = Just (("GIT_TERMINAL_PROMPT", "0") : filter ((/= "GIT_TERMINAL_PROMPT") . fst) env)})
        run :: [String] -> IO ()
        run args = Binary.untrackedExec git args "" silent
        -- what the branch resolves to after a fetch, as a full hash
        resolve :: IO Text
        resolve = do
            out <- Binary.untrackedExecOutput git ["-C", workdir, "rev-parse", "--verify", remoteRef] "" silent
            let hash = Text.strip (Text.pack (C8.unpack out))
            if Text.null hash then ioError (userError ("git rev-parse " <> remoteRef <> " answered nothing")) else pure hash
    pure
        Registry
            { registryName = renderSource source
            , registryFetch = \lbl previous -> do
                cloned <- doesDirectoryExist (workdir </> ".git")
                if cloned
                    then run ["-C", workdir, "fetch", "--quiet", "origin"]
                    else do
                        createDirectoryIfMissing True workdir
                        run (["clone", "--quiet"] ++ maybe [] (\b -> ["--branch", Text.unpack b, "--single-branch"]) source.sourceBranch ++ [Text.unpack source.sourceUrl, workdir])
                commit <- resolve
                let stamp = Stamp commit
                if Just stamp == previous
                    then pure Unchanged
                    else do
                        run ["-C", workdir, "reset", "--hard", "--quiet", Text.unpack commit]
                        let path = documentPathIn workdir source lbl
                        present <- doesFileExist path
                        if not present
                            then pure Absent
                            else do
                                bytes <- LByteString.readFile path
                                LByteString.length bytes `seq` pure (Found stamp (digestOf bytes) bytes)
            }
  where
    remoteRef = maybe "origin/HEAD" (\b -> "origin/" <> Text.unpack b) source.sourceBranch