packages feed

hix-0.9.0: lib/Hix/Managed/Git.hs

module Hix.Managed.Git where

import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Text as Text
import Distribution.Compat.CharParsing (string, try)
import Distribution.Parsec (Parsec (parsec))
import Distribution.Pretty (Pretty, pretty)
import Exon (exon)
import GHC.IsList (IsList (..))
import Path (Abs, Dir, Path, toFilePath)
import System.Exit (ExitCode (ExitFailure, ExitSuccess))
import System.Process.Typed (proc, readProcess, setEnv, setWorkingDir)
import Text.PrettyPrint (text)

import Hix.Class.Map (LookupMaybe, NMap, nTo)
import qualified Hix.Color as Color
import Hix.Data.Monad (M)
import Hix.Data.PackageId (PackageId (..))
import qualified Hix.Data.PackageName as PackageName
import Hix.Data.PackageName (LocalPackage (..))
import Hix.Data.Version (Version)
import Hix.Error (pathText)
import Hix.Monad (appContextDebug, fatalError, tryIOM, withTempDir)
import Hix.Pretty (prettyNt)

data ProcessResult =
  ProcessResult {
    code :: ExitCode,
    stdout :: [Text],
    stderr :: [Text]
  }
  deriving stock (Eq, Show, Generic)

newtype EnvKey =
  EnvKey Text
  deriving stock (Eq, Show)
  deriving newtype (IsString, ToString, Ord)

instance Pretty EnvKey where
  pretty = text . toString

newtype EnvVal =
  EnvVal Text
  deriving stock (Eq, Show)
  deriving newtype (IsString, ToString, Ord)

instance Pretty EnvVal where
  pretty = text . toString

newtype EnvVars =
  EnvVars (Map EnvKey EnvVal)
  deriving stock (Eq, Show)
  deriving newtype (IsList, Semigroup, Monoid)

instance NMap EnvVars EnvKey EnvVal LookupMaybe where

data GitCmd =
  GitCmd {
    repo :: Path Abs Dir,
    args :: [Text]
  }
  deriving stock (Eq, Show)

data GitResult =
  GitSuccess { stdout :: [Text] }
  |
  GitFailure { code :: Int, stdout :: [Text], stderr :: [Text] }
  deriving stock (Eq, Show)

gitProcess :: EnvVars -> GitCmd -> M ProcessResult
gitProcess env GitCmd {repo, args} = do
  appContextDebug [exon|running process #{Color.shellCommand cmdline}|] do
    result <$> tryIOM (readProcess conf)
  where
    conf = setEnv envString $ setWorkingDir (toFilePath repo) (proc "git" (toString <$> args))
    result (code, stdout, stderr) =
      ProcessResult {
        code,
        stdout = Text.lines (decodeUtf8 stdout),
        stderr = Text.lines (decodeUtf8 stderr)
      }

    envString = nTo env \ k v -> (toString k, toString v)

    cmdline = [exon|git #{Text.unwords args}|]

gitExec :: EnvVars -> GitCmd -> M GitResult
gitExec env cmd =
  gitProcess env cmd <&> \case
    ProcessResult {code, stdout, stderr}
      | ExitSuccess <- code
      -> GitSuccess stdout
      | ExitFailure number <- code
      -> GitFailure {code = number, stdout, stderr}

data GitBackend =
  GitBackend {
    exec :: GitCmd -> M GitResult
  }

data GitEnv =
  GitEnv {
    backend :: GitBackend,
    repo :: Path Abs Dir
  }

newtype GitApi api =
  GitApi { run :: ∀ a . Path Abs Dir -> (api -> M a) -> M a }

runGitApi ::
  GitApi api ->
  Path Abs Dir ->
  Text ->
  (api -> M a) ->
  M a
runGitApi (GitApi run) repo ctx use =
  run repo \ api ->
    appContextDebug [exon|operating on a git repo (#{ctx}) at #{Color.path (pathText repo)}|] do
      use api

gitCmdResult :: GitEnv -> [Text] -> M GitResult
gitCmdResult GitEnv {backend, repo} args =
  backend.exec GitCmd {repo, args}

gitCmd' :: GitEnv -> [Text] -> M (Either ([Text], [Text]) [Text])
gitCmd' env args = do
  gitCmdResult env args <&> \case
    GitSuccess {stdout} -> Right stdout
    GitFailure {stdout, stderr} -> Left (stdout, stderr)

gitError ::
  [Text] ->
  [Text] ->
  [Text] ->
  Text
gitError args stdout stderr =
  [exon|Git command failed: git #{Text.unwords args}
stdout:
#{Text.intercalate "\n" stdout}
stderr:
#{Text.intercalate "\n" stderr}|]

gitCmd :: GitEnv -> [Text] -> M [Text]
gitCmd env args =
  gitCmd' env args >>= \case
    Right stdout ->
      pure stdout
    Left (stderr, stdout) ->
      fatalError (gitError args stdout stderr)

gitCmd_ :: GitEnv -> [Text] -> M ()
gitCmd_ env = void . gitCmd env

data GitNative =
  GitNative {
    cmdResult :: [Text] -> M GitResult,
    cmd' :: [Text] -> M (Either ([Text], [Text]) [Text]),
    cmd :: [Text] -> M [Text],
    cmd_ :: [Text] -> M (),
    repo :: Path Abs Dir
  }

gitNative :: GitEnv -> GitNative
gitNative env =
  GitNative {
    cmdResult = gitCmdResult env,
    cmd' = gitCmd' env,
    cmd = gitCmd env,
    cmd_ = gitCmd_ env,
    repo = env.repo
  }

gitApiWithNativeEnv :: (GitEnv -> api) -> GitApi api
gitApiWithNativeEnv api =
  GitApi \ repo f -> f (api GitEnv {backend = GitBackend {exec = gitExec mempty}, repo})

gitApi :: (GitNative -> api) -> GitApi api
gitApi api =
  GitApi \ repo f -> f (api (gitNative GitEnv {backend = GitBackend {exec = gitExec mempty}, repo}))

gitApiNative :: GitApi GitNative
gitApiNative = gitApi id

-- TODO allow configuring the user data, especially for CI
gitEnvHermetic :: Path Abs Dir -> Path Abs Dir -> GitEnv
gitEnvHermetic home repo =
  GitEnv {backend = GitBackend {exec = gitExec env}, repo}
  where
    env =
      [
        ("HOME", coerce (pathText home)),
        ("GIT_CONFIG_NOSYSTEM", "1"),
        ("GIT_AUTHOR_NAME", "hix"),
        ("GIT_AUTHOR_EMAIL", "hix@tryp.io"),
        ("GIT_COMMITTER_NAME", "hix"),
        ("GIT_COMMITTER_EMAIL", "hix@tryp.io")
      ]

gitHermetic :: Path Abs Dir -> Path Abs Dir -> GitNative
gitHermetic home repo = gitNative (gitEnvHermetic home repo)

gitApiHermetic :: (GitNative -> api) -> GitApi api
gitApiHermetic consApi =
  GitApi \ repo f ->
    withTempDir "git-home" \ home ->
      f (consApi (gitNative (gitEnvHermetic home repo)))

gitApiNativeHermetic :: GitApi GitNative
gitApiNativeHermetic = gitApiHermetic id

runGitNative :: Path Abs Dir -> Text -> (GitNative -> M a) -> M a
runGitNative = runGitApi gitApiNative

runGitNativeHermetic :: Path Abs Dir -> Text -> (GitNative -> M a) -> M a
runGitNativeHermetic = runGitApi gitApiNativeHermetic

data Tag =
  Tag {
    package :: Maybe LocalPackage,
    version :: Version
  }
  deriving stock (Eq, Show, Generic)

instance Ord Tag where
  compare Tag {package = pl, version = vl} Tag {package = pr, version = vr} =
    compare pl pr <> compare vl vr

instance Parsec Tag where
  parsec =
    try (withPackage <$> parsec) <|> (Tag Nothing <$> parsec)
    where
      withPackage PackageId {..} = Tag (Just (LocalPackage name)) version

instance Pretty Tag where
  pretty Tag {package, version}
    | Just p <- package
    = [exon|#{pretty p}-#{pretty version}|]
    | otherwise
    = pretty version

-- TODO rename or refactor this.
data MaintBranch =
  MaintBranch {
    package :: LocalPackage,
    version :: Version
  }
  deriving stock (Eq, Show, Generic)

instance Parsec MaintBranch where
  parsec = do
    string "release/"
    package <- LocalPackage . PackageName.fromCabal <$> parsec
    string "/"
    version <- parsec
    pure MaintBranch {package = package, version}

instance Pretty MaintBranch where
  pretty MaintBranch {package = LocalPackage name, ..} =
    pretty PackageId {..}

newtype BranchName =
  BranchName Text
  deriving stock (Eq, Show)
  deriving newtype (IsString, Ord, ToJSON, FromJSON, Semigroup, Monoid)

instance Pretty BranchName where
  pretty = prettyNt