packages feed

pantry-0.9.3: src/Pantry/Repo.hs

{-# LANGUAGE NoImplicitPrelude   #-}
{-# LANGUAGE LambdaCase          #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE RecordWildCards     #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections       #-}

module Pantry.Repo
  ( fetchReposRaw
  , fetchRepos
  , getRepo
  , getRepoKey
  , createRepoArchive
  , withRepoArchive
  , withRepo
  ) where

import           Database.Persist.Class.PersistEntity ( Entity (..) )
import           Pantry.Archive ( getArchivePackage )
import           Pantry.Storage
                   ( getTreeForKey, loadPackageById, loadRepoCache
                   , storeRepoCache, withStorage
                   )
import           Pantry.Types
                   ( AggregateRepo (..), ArchiveLocation (..), HasPantryConfig
                   , Package (..), PackageMetadata (..), PantryException (..)
                   , RawArchive (..), RawPackageLocationImmutable (..)
                   , RawPackageMetadata (..), RelFilePath (..), Repo (..)
                   , RepoType (..), ResolvedPath (..), SimpleRepo (..)
                   , TreeKey (..), arToSimpleRepo, rToSimpleRepo
                   , toAggregateRepos, toRawPM
                   )
import           Path.IO ( resolveFile' )
import           RIO
import           RIO.ByteString ( isInfixOf )
import           RIO.ByteString.Lazy ( toStrict )
import           RIO.Directory ( doesDirectoryExist )
import           RIO.FilePath ( (</>) )
import qualified RIO.Map as Map
import           RIO.Process
                   ( ExitCodeException (..), HasProcessContext, proc
                   , readProcess, readProcess_, withModifyEnvVars
                   , withWorkingDir
                   )
import qualified RIO.Text as T
import           System.Console.ANSI ( hSupportsANSIWithoutEmulation )
import           System.IsWindows ( osIsWindows )

data TarType = Gnu | Bsd

getGitTarType :: (HasProcessContext env, HasLogFunc env) => RIO env TarType
getGitTarType = if osIsWindows
  then do
    (_, stdoutBS, _) <- proc "git" ["--version"] readProcess
    let bs = toStrict stdoutBS
    -- If using Git for Windows, then assume that the tar type within
    -- `git submodule foreach <command>` is the Git-supplied\MSYS2-supplied
    -- GNU tar
    if "windows" `isInfixOf` bs then pure Gnu else getTarType
  else getTarType

getTarType :: (HasProcessContext env, HasLogFunc env) => RIO env TarType
getTarType = do
  (_, stdoutBS, _) <- proc "tar" ["--version"] readProcess
  let bs = toStrict stdoutBS
  pure $ if "GNU" `isInfixOf` bs then Gnu else Bsd

-- | Like 'fetchRepos', except with 'RawPackageMetadata' instead of
-- 'PackageMetadata'.
--
-- @since 0.5.3
fetchReposRaw ::
     (HasPantryConfig env, HasLogFunc env, HasProcessContext env)
  => [(Repo, RawPackageMetadata)]
  -> RIO env ()
fetchReposRaw pairs = do
  let repos = toAggregateRepos pairs
  logDebug (displayShow repos)
  for_ repos getRepos

-- | Fetch the given repositories at once and populate the pantry database.
--
-- @since 0.5.3
fetchRepos ::
     (HasPantryConfig env, HasLogFunc env, HasProcessContext env)
  => [(Repo, PackageMetadata)]
  -> RIO env ()
fetchRepos pairs = do
  -- TODO be more efficient, group together shared archives
  fetchReposRaw $ map (second toRawPM) pairs

getRepoKey ::
     forall env. (HasPantryConfig env, HasLogFunc env, HasProcessContext env)
  => Repo
  -> RawPackageMetadata
  -> RIO env TreeKey
getRepoKey repo rpm = packageTreeKey <$> getRepo repo rpm -- potential optimization

getRepo ::
     forall env. (HasPantryConfig env, HasLogFunc env, HasProcessContext env)
  => Repo
  -> RawPackageMetadata
  -> RIO env Package
getRepo repo pm = do
  withCache $ getRepo' repo pm
 where
  withCache :: RIO env Package -> RIO env Package
  withCache inner = do
    mtid <- withStorage (loadRepoCache repo)
    case mtid of
      Just tid -> withStorage $ loadPackageById (RPLIRepo repo pm) tid
      Nothing -> do
        package <- inner
        withStorage $ do
          ment <- getTreeForKey $ packageTreeKey package
          case ment of
            Nothing ->  error $
                "invariant violated, Tree not found: "
              ++ show (packageTreeKey package)
            Just (Entity tid _) -> storeRepoCache repo (repoSubdir repo) tid
        pure package

getRepo' ::
     forall env. (HasPantryConfig env, HasLogFunc env, HasProcessContext env)
  => Repo
  -> RawPackageMetadata
  -> RIO env Package
getRepo' repo@Repo{..} rpm = do
  withRepoArchive (rToSimpleRepo repo) $ \tarball -> do
    abs' <- resolveFile' tarball
    getArchivePackage
      (RPLIRepo repo rpm)
      RawArchive
        { raLocation = ALFilePath $ ResolvedPath
            { resolvedRelative = RelFilePath $ T.pack tarball
            , resolvedAbsolute = abs'
            }
        , raHash = Nothing
        , raSize = Nothing
        , raSubdir = repoSubdir
        }
      rpm

getRepos ::
     forall env. (HasPantryConfig env, HasLogFunc env, HasProcessContext env)
  => AggregateRepo
  -> RIO env [Package]
getRepos repo@(AggregateRepo (SimpleRepo{..}) repoSubdirs) = withCache getRepos'
 where
  withCache inner = do
    pkgs <- forM repoSubdirs $ \(subdir, rpm) -> withStorage $ do
      loadRepoCache (Repo sRepoUrl sRepoCommit sRepoType subdir) >>= \case
        Just tid ->
          fmap Right $ (, subdir) <$> loadPackageById (RPLIRepo (Repo sRepoUrl sRepoCommit sRepoType subdir) rpm) tid
        Nothing  -> pure $ Left (subdir, rpm)
    let (missingPkgs, cachedPkgs) = partitionEithers pkgs
    newPkgs <-
      if null missingPkgs
      then pure []
      else do
        packages <- inner repo { aRepoSubdirs = missingPkgs }
        forM packages $ \(package, subdir) -> do
          withStorage $ do
            ment <- getTreeForKey $ packageTreeKey package
            case ment of
              Nothing -> error $
                   "invariant violated, Tree not found: "
                ++ show (packageTreeKey package)
              Just (Entity tid _) ->
                storeRepoCache (Repo sRepoUrl sRepoCommit sRepoType subdir) subdir tid
          pure package
    pure (nubOrd ((fst <$> cachedPkgs) ++ newPkgs))

getRepos' ::
     forall env. (HasPantryConfig env, HasLogFunc env, HasProcessContext env)
  => AggregateRepo
  -> RIO env [(Package, Text)] -- ^ [(package, subdir)]
getRepos' ar@(AggregateRepo (SimpleRepo{..}) repoSubdirs) = do
  withRepoArchive (arToSimpleRepo ar) $ \tarball -> do
    abs' <- resolveFile' tarball
    forM repoSubdirs $ \(subdir, rpm) -> do
      (,subdir) <$> getArchivePackage
        (RPLIRepo (Repo sRepoUrl sRepoCommit sRepoType subdir) rpm)
        RawArchive
          { raLocation = ALFilePath $ ResolvedPath
              { resolvedRelative = RelFilePath $ T.pack tarball
              , resolvedAbsolute = abs'
              }
          , raHash = Nothing
          , raSize = Nothing
          , raSubdir = subdir
          }
        rpm

-- | Fetch a repository and create a (temporary) tar archive from it. Pass the
-- path of the generated tarball to the given action.
withRepoArchive ::
     forall env a. (HasLogFunc env, HasProcessContext env)
  => SimpleRepo
  -> (FilePath -> RIO env a)
  -> RIO env a
withRepoArchive sr action =
  withSystemTempDirectory "with-repo-archive" $ \tmpdirArchive -> do
    let tarball = tmpdirArchive </> "foo.tar"
    createRepoArchive sr tarball
    action tarball

-- | Run a git command, setting appropriate environment variable settings. See
-- <https://github.com/commercialhaskell/stack/issues/3748>.
runGitCommand ::
     (HasLogFunc env, HasProcessContext env)
  => [String] -- ^ args
  -> RIO env ()
runGitCommand args =
  withModifyEnvVars go $
  void $ proc "git" args readProcess_
 where
  go = Map.delete "GIT_DIR"
     . Map.delete "GIT_CEILING_DIRECTORIES"
     . Map.delete "GIT_WORK_TREE"
     . Map.delete "GIT_INDEX_FILE"
     . Map.delete "GIT_OBJECT_DIRECTORY" -- possible optimization: set this to something Pantry controls
     . Map.delete "GIT_ALTERNATE_OBJECT_DIRECTORIES"

-- Include submodules files into the archive: use `git submodule foreach` to
-- execute `git archive` in each submodule and generate tar archive. With bsd
-- tar, the generated archive is extracted to a temporary folder and the files
-- in them are added to the tarball referenced by the variable tarball in the
-- haskell code. This is done in GNU tar with -A option.
archiveSubmodules ::
     (HasLogFunc env, HasProcessContext env)
  => FilePath
  -> RIO env ()
archiveSubmodules tarball = do
  tarType <- getGitTarType
  let forceLocal =
          if osIsWindows
          then " --force-local "
          else mempty
  case tarType of
    Gnu -> runGitCommand
      [ "submodule"
      , "foreach"
      , "--recursive"
      , "git -c core.autocrlf=false archive --prefix=$displaypath/ -o bar.tar HEAD; "
        <> "tar" <> forceLocal <> " -Af " <> tarball <> " bar.tar"
      ]
    Bsd -> runGitCommand
      [ "submodule"
      , "foreach"
      , "--recursive"
      , "git -c core.autocrlf=false archive --prefix=$displaypath/ -o bar.tar HEAD; "
        <> "rm -rf temp; mkdir temp; mv bar.tar temp/; "
        <> "tar -C temp -xf temp/bar.tar; "
        <> "rm temp/bar.tar; "
        <> "tar -C temp -rf " <> tarball <> " . ;"
      ]

-- | Run an hg command
runHgCommand ::
     (HasLogFunc env, HasProcessContext env)
  => [String] -- ^ args
  -> RIO env ()
runHgCommand args = void $ proc "hg" args readProcess_

-- | Create a tarball containing files from a repository
createRepoArchive ::
     forall env. (HasLogFunc env, HasProcessContext env)
  => SimpleRepo
  -> FilePath -- ^ Output tar archive filename
  -> RIO env ()
createRepoArchive sr tarball = do
  withRepo sr $
    case sRepoType sr of
      RepoGit -> do
        runGitCommand
          ["-c", "core.autocrlf=false", "archive", "-o", tarball, "HEAD"]
        archiveSubmodules tarball
      RepoHg -> runHgCommand ["archive", tarball, "-X", ".hg_archival.txt"]


-- | Clone the repository (and, in the case of Git and if necessary, fetch the
-- specific commit) and execute the action with the working directory set to the
-- repository root.
--
-- @since 0.1.0.0
withRepo ::
     forall env a. (HasLogFunc env, HasProcessContext env)
  => SimpleRepo
  -> RIO env a
  -> RIO env a
withRepo sr@SimpleRepo{..} action =
  withSystemTempDirectory "with-repo" $ \tmpDir -> do
    let repoUrl = T.unpack sRepoUrl
        repoCommit = T.unpack sRepoCommit
        dir = tmpDir </> "cloned"
        (runCommand, resetArgs) =
          case sRepoType of
            RepoGit ->
              ( runGitCommand
              , ["reset", "--hard", repoCommit]
              )
            RepoHg ->
              ( runHgCommand
              , ["update", "-C", repoCommit]
              )
        fetchCommit = ["fetch", repoUrl, repoCommit]
        submoduleArgs = ["submodule", "update", "--init", "--recursive"]
        fixANSIForWindows =
          -- On Windows 10, an upstream issue with the `git clone` command means
          -- that command clears, but does not then restore, the
          -- ENABLE_VIRTUAL_TERMINAL_PROCESSING flag for native terminals. The
          -- following hack re-enables the lost ANSI-capability.
          when osIsWindows $ void $ liftIO $ hSupportsANSIWithoutEmulation stdout
    logInfo $ "Cloning " <> display sRepoCommit <> " from " <> display sRepoUrl
    runCommand ["clone", repoUrl, dir]
    fixANSIForWindows
    created <- doesDirectoryExist dir
    unless created $ throwIO $ FailedToCloneRepo sr

    -- Note we do not immediately change directories into the new temporary
    -- directory, but instead wait until we have finished cloning the repo. This
    -- is because the repo URL may be a relative path on the local filesystem,
    -- and we should interpret it as relative to the current directory, not the
    -- temporary directory.
    withWorkingDir dir $ do
      case sRepoType of
        RepoGit -> do
          catch
            -- This will result in a failure exit code if the specified commit
            -- is not in the clone of the repository.
            (runCommand resetArgs)
            ( \(_ :: ExitCodeException) -> do
               -- Perhaps the specified commit is not one that is brought across
               -- by `git clone`. For example, in the case of a GitHub
               -- repository, it may be a commit from a different repository
               -- that is the subject of an unmerged pull request. Try to fetch
               -- the specific commit and then try again.
               runCommand fetchCommit
               runCommand resetArgs
            )
          runCommand submoduleArgs
          fixANSIForWindows
        RepoHg -> runCommand resetArgs
      action