diff --git a/cabal-install/Distribution/Client/Get.hs b/cabal-install/Distribution/Client/Get.hs index c8f1f6c8b88bf615545cc67b255ef7cb2be4c4fd..e94e304be58b1683a97a1d3bd6b7e287c8e17967 100644 --- a/cabal-install/Distribution/Client/Get.hs +++ b/cabal-install/Distribution/Client/Get.hs @@ -14,7 +14,10 @@ ----------------------------------------------------------------------------- module Distribution.Client.Get ( - get + get, + forkPackages, + ForkException(..), + forkPackagesRepo, ) where import Prelude () @@ -25,39 +28,33 @@ import Distribution.Package import Distribution.Simple.Setup ( Flag(..), fromFlag, fromFlagOrDefault, flagToMaybe ) import Distribution.Simple.Utils - ( notice, die', info, rawSystemExitCode, writeFileAtomic ) + ( notice, die', info, writeFileAtomic ) import Distribution.Verbosity ( Verbosity ) -import Distribution.Text(display) +import Distribution.Text (display) import qualified Distribution.PackageDescription as PD +import Distribution.Simple.Program + ( programName ) import Distribution.Client.Setup ( GlobalFlags(..), GetFlags(..), RepoContext(..) ) import Distribution.Client.Types import Distribution.Client.Targets import Distribution.Client.Dependency +import Distribution.Client.VCS import Distribution.Client.FetchUtils import qualified Distribution.Client.Tar as Tar (extractTarGzFile) import Distribution.Client.IndexUtils as IndexUtils ( getSourcePackagesAtIndexState ) -import Distribution.Client.Compat.Process - ( readProcessWithExitCode ) -import Distribution.Compat.Exception - ( catchIO ) - import Distribution.Solver.Types.SourcePackage import Control.Exception - ( finally ) + ( Exception(..), catch, throwIO ) import Control.Monad - ( forM_, mapM_ ) -import qualified Data.Map -import Data.Ord - ( comparing ) + ( mapM, forM_, mapM_ ) +import qualified Data.Map as Map import System.Directory - ( createDirectoryIfMissing, doesDirectoryExist, doesFileExist - , getCurrentDirectory, setCurrentDirectory - ) + ( createDirectoryIfMissing, doesDirectoryExist, doesFileExist ) import System.Exit ( ExitCode(..) ) import System.FilePath @@ -108,12 +105,10 @@ get verbosity repoCtxt globalFlags getFlags userTargets = do standardInstallPolicy mempty sourcePkgDb pkgSpecifiers prefix = fromFlagOrDefault "" (getDestDir getFlags) + kind = fromFlag . getSourceRepository $ getFlags fork :: [UnresolvedSourcePackage] -> IO () - fork pkgs = do - let kind = fromFlag . getSourceRepository $ getFlags - branchers <- findUsableBranchers - mapM_ (forkPackage verbosity branchers prefix kind) pkgs + fork = forkPackages verbosity prefix kind unpack :: [UnresolvedSourcePackage] -> IO () unpack pkgs = do @@ -143,6 +138,7 @@ checkTarget verbosity target = case target of UserTargetLocalCabalFile file -> die' verbosity (notTarball file) _ -> return () where + notTarball :: String -> String notTarball t = "The 'get' command is for tarball packages. " ++ "The target '" ++ t ++ "' is not a tarball." @@ -181,171 +177,125 @@ unpackPackage verbosity prefix pkgid descOverride pkgPath = do -- * Forking the source repository -- ------------------------------------------------------------ -data BranchCmd = BranchCmd (Verbosity -> FilePath -> IO ExitCode) - -data Brancher = Brancher - { brancherBinary :: String - , brancherBuildCmd :: PD.SourceRepo -> Maybe BranchCmd - } - --- | The set of all supported branch drivers. -allBranchers :: [(PD.RepoType, Brancher)] -allBranchers = - [ (PD.Bazaar, branchBzr) - , (PD.Darcs, branchDarcs) - , (PD.Git, branchGit) - , (PD.Mercurial, branchHg) - , (PD.SVN, branchSvn) - ] - --- | Find which usable branch drivers (selected from 'allBranchers') are --- available and usable on the local machine. --- --- Each driver's main command is run with @--help@, and if the child process --- exits successfully, that brancher is considered usable. -findUsableBranchers :: IO (Data.Map.Map PD.RepoType Brancher) -findUsableBranchers = do - let usable (_, brancher) = flip catchIO (const (return False)) $ do - let cmd = brancherBinary brancher - (exitCode, _, _) <- readProcessWithExitCode cmd ["--help"] "" - return (exitCode == ExitSuccess) - pairs <- filterM usable allBranchers - return (Data.Map.fromList pairs) - --- | Fork a single package from a remote source repository to the local --- file system. -forkPackage :: Verbosity - -> Data.Map.Map PD.RepoType Brancher - -- ^ Branchers supported by the local machine. - -> FilePath - -- ^ The directory in which new branches or repositories will - -- be created. - -> (Maybe PD.RepoKind) - -- ^ Which repo to choose. - -> SourcePackage loc - -- ^ The package to fork. - -> IO () -forkPackage verbosity branchers prefix kind src = do - let desc = PD.packageDescription (packageDescription src) - pkgid = display (packageId src) - pkgname = display (packageName src) - destdir = prefix </> pkgname - - destDirExists <- doesDirectoryExist destdir - when destDirExists $ do - die' verbosity ("The directory " ++ show destdir ++ " already exists, not forking.") - - destFileExists <- doesFileExist destdir - when destFileExists $ do - die' verbosity ("A file " ++ show destdir ++ " is in the way, not forking.") - - let repos = PD.sourceRepos desc - case findBranchCmd branchers repos kind of - Just (BranchCmd io) -> do - exitCode <- io verbosity destdir - case exitCode of - ExitSuccess -> return () - ExitFailure _ -> die' verbosity ("Couldn't fork package " ++ pkgid) - Nothing -> case repos of - [] -> die' verbosity ("Package " ++ pkgid - ++ " does not have any source repositories.") - _ -> die' verbosity ("Package " ++ pkgid - ++ " does not have any usable source repositories.") - --- | Given a set of possible branchers, and a set of possible source --- repositories, find a repository that is both 1) likely to be specific to --- this source version and 2) is supported by the local machine. -findBranchCmd :: Data.Map.Map PD.RepoType Brancher -> [PD.SourceRepo] - -> (Maybe PD.RepoKind) -> Maybe BranchCmd -findBranchCmd branchers allRepos maybeKind = cmd where - -- Sort repositories by kind, from This to Head to Unknown. Repositories - -- with equivalent kinds are selected based on the order they appear in - -- the Cabal description file. - repos' = sortBy (comparing thisFirst) allRepos - thisFirst r = case PD.repoKind r of - PD.RepoThis -> 0 :: Int - PD.RepoHead -> case PD.repoTag r of - -- If the type is 'head' but the author specified a tag, they - -- probably meant to create a 'this' repository but screwed up. - Just _ -> 0 - Nothing -> 1 - PD.RepoKindUnknown _ -> 2 - - -- If the user has specified the repo kind, filter out the repositories - -- she's not interested in. - repos = maybe repos' (\k -> filter ((==) k . PD.repoKind) repos') maybeKind - - repoBranchCmd repo = do - t <- PD.repoType repo - brancher <- Data.Map.lookup t branchers - brancherBuildCmd brancher repo - - cmd = listToMaybe (mapMaybe repoBranchCmd repos) - --- | Branch driver for Bazaar. -branchBzr :: Brancher -branchBzr = Brancher "bzr" $ \repo -> do - src <- PD.repoLocation repo - let args dst = case PD.repoTag repo of - Just tag -> ["branch", src, dst, "-r", "tag:" ++ tag] - Nothing -> ["branch", src, dst] - return $ BranchCmd $ \verbosity dst -> do - notice verbosity ("bzr: branch " ++ show src) - rawSystemExitCode verbosity "bzr" (args dst) - --- | Branch driver for Darcs. -branchDarcs :: Brancher -branchDarcs = Brancher "darcs" $ \repo -> do - src <- PD.repoLocation repo - let args dst = case PD.repoTag repo of - Just tag -> ["get", src, dst, "-t", tag] - Nothing -> ["get", src, dst] - return $ BranchCmd $ \verbosity dst -> do - notice verbosity ("darcs: get " ++ show src) - rawSystemExitCode verbosity "darcs" (args dst) - --- | Branch driver for Git. -branchGit :: Brancher -branchGit = Brancher "git" $ \repo -> do - src <- PD.repoLocation repo - let postClone verbosity dst = case PD.repoTag repo of - Just t -> do - cwd <- getCurrentDirectory - setCurrentDirectory dst - finally - (rawSystemExitCode verbosity "git" ["checkout", t]) - (setCurrentDirectory cwd) - Nothing -> return ExitSuccess - return $ BranchCmd $ \verbosity dst -> do - notice verbosity ("git: clone " ++ show src) - code <- rawSystemExitCode verbosity "git" (["clone", src, dst] ++ - case PD.repoBranch repo of - Nothing -> [] - Just b -> ["--branch", b]) - case code of - ExitFailure _ -> return code - ExitSuccess -> postClone verbosity dst - --- | Branch driver for Mercurial. -branchHg :: Brancher -branchHg = Brancher "hg" $ \repo -> do - src <- PD.repoLocation repo - let branchArgs = case PD.repoBranch repo of - Just b -> ["--branch", b] - Nothing -> [] - let tagArgs = case PD.repoTag repo of - Just t -> ["--rev", t] - Nothing -> [] - let args dst = ["clone", src, dst] ++ branchArgs ++ tagArgs - return $ BranchCmd $ \verbosity dst -> do - notice verbosity ("hg: clone " ++ show src) - rawSystemExitCode verbosity "hg" (args dst) - --- | Branch driver for Subversion. -branchSvn :: Brancher -branchSvn = Brancher "svn" $ \repo -> do - src <- PD.repoLocation repo - let args dst = ["checkout", src, dst] - return $ BranchCmd $ \verbosity dst -> do - notice verbosity ("svn: checkout " ++ show src) - rawSystemExitCode verbosity "svn" (args dst) +forkPackages :: Verbosity + -> FilePath -- ^ destination dir prefix + -> Maybe RepoKind -- ^ + -> [SourcePackage loc] -- ^ the packages + -> IO () +forkPackages verbosity destDirPrefix preferredRepoKind = + forkPackagesRepo verbosity destDirPrefix preferredRepoKind + . map (\pkg -> (packageId pkg, packageSourceRepos pkg)) + where + packageSourceRepos :: SourcePackage loc -> [SourceRepo] + packageSourceRepos = PD.sourceRepos + . PD.packageDescription + . packageDescription + +data ForkException = + ForkExceptionNoSourceRepos PackageId + | ForkExceptionNoSourceReposOfKind PackageId (Maybe RepoKind) + | ForkExceptionNoRepoType PackageId SourceRepo + | ForkExceptionUnsupportedRepoType PackageId SourceRepo RepoType + | ForkExceptionNoRepoLocation PackageId SourceRepo + | ForkExceptionDestinationExists PackageId FilePath Bool + | ForkExceptionFailedWithExitCode PackageId SourceRepo + String ExitCode + deriving (Show, Eq) + +instance Exception ForkException where + displayException (ForkExceptionNoSourceRepos pkgid) = + "Cannot fetch a source repository for package " ++ display pkgid + ++ ". The package does not specify any source repositories." + + displayException (ForkExceptionNoSourceReposOfKind pkgid repoKind) = + "Cannot fetch a source repository for package " ++ display pkgid + ++ ". The package does not specify a source repository of the requested " + ++ "kind" ++ maybe "." (\k -> " (kind " ++ display k ++ ").") repoKind + + displayException (ForkExceptionNoRepoType pkgid _repo) = + "Cannot fetch the source repository for package " ++ display pkgid + ++ ". The package's description specifies a source repository but does " + ++ "not specify the repository 'type' field (e.g. git, darcs or hg)." + + displayException (ForkExceptionUnsupportedRepoType pkgid _repo repoType) = + "Cannot fetch the source repository for package " ++ display pkgid + ++ ". The repository type '" ++ display repoType + ++ "' is not yet supported." + + displayException (ForkExceptionNoRepoLocation pkgid _repo) = + "Cannot fetch the source repository for package " ++ display pkgid + ++ ". The package's description specifies a source repository but does " + ++ "not specify the repository 'location' field (i.e. the URL)." + + displayException (ForkExceptionDestinationExists pkgid dest isdir) = + "Not fetching the source repository for package " ++ display pkgid ++ ". " + ++ if isdir then "The destination directory " ++ dest ++ " already exists." + else "A file " ++ dest ++ " is in the way." + + displayException (ForkExceptionFailedWithExitCode pkgid repo vcsprogname + exitcode) = + "Failed to fetch the source repository for package " ++ display pkgid + ++ maybe "" (", repository location " ++) (PD.repoLocation repo) ++ " (" + ++ vcsprogname ++ " failed with " ++ show exitcode ++ ")." + + +forkPackagesRepo :: Verbosity + -> FilePath + -> Maybe RepoKind + -> [(PackageId, [SourceRepo])] + -> IO () +forkPackagesRepo verbosity destDirPrefix preferredRepoKind pkgrepos = do + + -- Do a bunch of checks and collect the required info + pkgrepos' <- mapM (prepareClonePackageRepo + preferredRepoKind destDirPrefix) pkgrepos + + -- Configure the VCS drivers for all the repository types we may need + vcss <- configureVCSs verbosity $ + Map.fromList [ (vcsRepoType vcs, vcs) + | (_, _, vcs, _, _) <- pkgrepos' ] + + -- Now execute all the required commands for each repo + sequence_ + [ cloneSourceRepo verbosity vcs' repo srcURL destDir + `catch` \exitcode -> + throwIO (ForkExceptionFailedWithExitCode + pkgid repo (programName (vcsProgram vcs)) exitcode) + | (pkgid, repo, vcs, srcURL, destDir) <- pkgrepos' + , let Just vcs' = Map.lookup (vcsRepoType vcs) vcss + ] + + +prepareClonePackageRepo :: Maybe RepoKind + -> FilePath + -> (PackageId, [SourceRepo]) + -> IO (PackageId, SourceRepo, + VCS Program, String, FilePath) +prepareClonePackageRepo preferredRepoKind destDirPrefix + (pkgid, repos) = do + repo <- case selectPackageSourceRepo preferredRepoKind repos of + Nothing | null repos -> throwIO (ForkExceptionNoSourceRepos pkgid) + Nothing -> throwIO (ForkExceptionNoSourceReposOfKind pkgid + preferredRepoKind) + Just repo -> return repo + + (vcs, srcURL) <- case selectSourceRepoVCS repo of + Right x -> return x + Left SourceRepoRepoTypeUnspecified -> + throwIO (ForkExceptionNoRepoType pkgid repo) + + Left (SourceRepoRepoTypeUnsupported repoType) -> + throwIO (ForkExceptionUnsupportedRepoType pkgid repo repoType) + + Left SourceRepoLocationUnspecified -> + throwIO (ForkExceptionNoRepoLocation pkgid repo) + + destDirExists <- doesDirectoryExist destDir + destFileExists <- doesFileExist destDir + when (destDirExists || destFileExists) $ + throwIO (ForkExceptionDestinationExists pkgid destDir destDirExists) + + return (pkgid, repo, vcs, srcURL, destDir) + where + destDir = destDirPrefix </> display (packageName pkgid) + diff --git a/cabal-install/Distribution/Client/VCS.hs b/cabal-install/Distribution/Client/VCS.hs new file mode 100644 index 0000000000000000000000000000000000000000..4a3fb1dcb6ba815f521c7b619bf1fe91ea0d797b --- /dev/null +++ b/cabal-install/Distribution/Client/VCS.hs @@ -0,0 +1,559 @@ +{-# LANGUAGE NamedFieldPuns, RecordWildCards #-} +module Distribution.Client.VCS ( + VCS(vcsSyncRepos), + vcsRepoType, + vcsProgram, + SourceRepo, + RepoType, + RepoKind, + Program, + ConfiguredProgram, +-- findVcsCloneInvocation, + selectPackageSourceRepo, + selectSourceRepoVCS, + SourceRepoProblem(..), + configureVCS, + configureVCSs, +-- findUsableVCSs, + cloneSourceRepo, + knownVCSs, + vcsBzr, + vcsDarcs, + vcsGit, + vcsHg, + vcsSvn, + ) where + +import Prelude () +import Distribution.Client.Compat.Prelude + +import Distribution.Types.SourceRepo + ( SourceRepo(..), RepoType(..), RepoKind(..) ) +import Distribution.Client.FileMonitor + ( MonitorFilePath, monitorDirectoryExistence ) +import Distribution.Client.RebuildMonad + ( Rebuild, monitorFiles ) +import Distribution.Verbosity as Verbosity + ( Verbosity, normal ) +import Distribution.Simple.Program + ( Program(programFindVersion, programName) + , ConfiguredProgram(programVersion) + , simpleProgram, findProgramVersion + , ProgramInvocation(..), programInvocation, runProgramInvocation + , ProgramDb, emptyProgramDb, knownPrograms, configureProgram + , lookupProgram ) +import Distribution.Version + ( mkVersion ) + +import Control.Monad + ( mapM_ ) +import qualified Data.Char as Char +import qualified Data.Map as Map +import qualified Data.Set as Set +import Data.Set (Set) +import Data.Ord + ( comparing ) +import System.FilePath + ( takeDirectory ) +import System.Directory + ( doesDirectoryExist ) + + +-- | A driver for a version control system, e.g. git, darcs etc. +-- +data VCS program = VCS { + -- | The type of repository this driver is for. + vcsRepoType :: RepoType, + + -- | The vcs program itself. + -- This is used at type 'Program' and 'ConfiguredProgram'. + vcsProgram :: program, + + -- | The program invocation(s) to get\/clone a repository into a fresh + -- local directory. + vcsCloneRepo :: Verbosity + -> ConfiguredProgram + -> SourceRepo + -> FilePath -- ^ Source URL + -> FilePath -- ^ Destination directory + -> [ProgramInvocation], + + -- | The program invocation(s) to synchronise a whole set of /related/ + -- repositories with corresponding local directories. Also returns the + -- files that the command depends on, for change monitoring. + vcsSyncRepos :: Verbosity + -> ConfiguredProgram + -> [(SourceRepo, FilePath)] + -> IO [MonitorFilePath] + } + + +-- | The set of all supported VCS drivers. +-- +knownVCSs :: [VCS Program] +knownVCSs = [ vcsBzr, vcsDarcs, vcsGit, vcsHg, vcsSvn ] + + +-- ------------------------------------------------------------ +-- * Selecting repos and drivers +-- ------------------------------------------------------------ + +-- | Pick the 'SourceRepo' to use to get the package sources from. +-- +-- Note that this does /not/ depend on what 'VCS' drivers we are able to +-- successfully configure. It is based only on the 'SourceRepo's declared +-- in the package, and optionally on a preferred 'RepoKind'. +-- +selectPackageSourceRepo :: Maybe RepoKind + -> [SourceRepo] + -> Maybe SourceRepo +selectPackageSourceRepo preferredRepoKind = + listToMaybe + -- Sort repositories by kind, from This to Head to Unknown. Repositories + -- with equivalent kinds are selected based on the order they appear in + -- the Cabal description file. + . sortBy (comparing thisFirst) + -- If the user has specified the repo kind, filter out the repositories + -- they're not interested in. + . filter (\repo -> maybe True (repoKind repo ==) preferredRepoKind) + where + thisFirst :: SourceRepo -> Int + thisFirst r = case repoKind r of + RepoThis -> 0 + RepoHead -> case repoTag r of + -- If the type is 'head' but the author specified a tag, they + -- probably meant to create a 'this' repository but screwed up. + Just _ -> 0 + Nothing -> 1 + RepoKindUnknown _ -> 2 + +data SourceRepoProblem = SourceRepoRepoTypeUnspecified + | SourceRepoRepoTypeUnsupported RepoType + | SourceRepoLocationUnspecified + +-- | Given a single 'SourceRepo', pick which VCS we should use to fetch it. +-- +-- It also validates that the 'SourceRepo' specifies a repo location URL, and +-- returns that URL string. +-- +selectSourceRepoVCS :: SourceRepo + -> Either SourceRepoProblem + (VCS Program, String) +selectSourceRepoVCS = \repo -> do + rtype <- repoType repo ?! SourceRepoRepoTypeUnspecified + vcs <- Map.lookup rtype knownVCSs' ?! SourceRepoRepoTypeUnsupported rtype + url <- repoLocation repo ?! SourceRepoLocationUnspecified + return (vcs, url) + where + a ?! e = maybe (Left e) Right a + + -- The 'knownVCSs' organised by 'RepoType'. + knownVCSs' = Map.fromList [ (vcsRepoType vcs, vcs) | vcs <- knownVCSs ] + + +{- +-- | Find which usable VCS drivers (selected from 'knownVCSs') are +-- available and usable on the local machine for the given 'RepoType's. +-- +findUsableVCSs :: Verbosity + -> Set RepoType -- ^ Which repo types we are interested in. + -> IO (Map RepoType (VCS ConfiguredProgram)) +findUsableVCSs verbosity repoTypes = do + progdb <- configurePrograms + [ vcsProgram vcs + | vcs <- knownVCSs + , vcsRepoType vcs `Set.member` repoTypes ] + + let vcssByProg = Map.fromList + [ (programName (vcsProgram vcs), vcs) + | vcs <- knownVCSs ] + usableProgs = Map.fromList + [ (programName prog, cprog) + | (prog, Just cprog) <- knownPrograms progdb ] + usableVCSs = reindexByRepoType $ + Map.intersectionWith + (\prog vcs -> vcs { vcsProgram = prog}) + usableProgs + vcssByProg + + return usableVCSs + where + reindexByRepoType :: Map a (VCS p) -> Map RepoType (VCS p) + reindexByRepoType = Map.fromList + . map (\vcs -> (vcsRepoType vcs, vcs)) + . Map.elems + + --TODO: export this from Distribution.Simple.Program.Db + configurePrograms :: [Program] -> IO ProgramDb + configurePrograms = foldM (flip (configureProgram verbosity)) emptyProgramDb +-} + +configureVCS :: Verbosity + -> VCS Program + -> IO (Maybe (VCS ConfiguredProgram)) +configureVCS verbosity vcs@VCS{vcsProgram = prog} = + selectConfigured <$> configureProgram verbosity prog emptyProgramDb + where + selectConfigured :: ProgramDb -> Maybe (VCS ConfiguredProgram) + selectConfigured = fmap (\prog' -> vcs { vcsProgram = prog' }) + . lookupProgram prog + +--TODO: use requireProgram, we don't need optional configuration + +configureVCSs :: Verbosity + -> Map RepoType (VCS Program) + -> IO (Map RepoType (VCS ConfiguredProgram)) +configureVCSs verbosity vcss = + keepConfigured <$> traverse (configureVCS verbosity) vcss + where + keepConfigured :: Map a (Maybe b) -> Map a b + keepConfigured = Map.mapMaybe id + + +-- ------------------------------------------------------------ +-- * Running the driver +-- ------------------------------------------------------------ + +-- | Clone a single source repo into a fresh directory, using a configured VCS. +-- +-- This is for making a new copy, not synchronising an existing copy. It will +-- fail if the destination directory already exists. +-- +cloneSourceRepo :: Verbosity + -> VCS ConfiguredProgram + -> SourceRepo + -> String -- ^ Source URL + -> FilePath -- ^ Destination directory + -> IO () +cloneSourceRepo verbosity vcs repo srcurl destdir = + mapM_ (runProgramInvocation verbosity) invocations + where + invocations = vcsCloneRepo vcs verbosity + (vcsProgram vcs) repo + srcurl destdir + + +-- | Syncronise a set of 'SourceRepo's referring to the same repository with +-- corresponding local directories. The local directories may or may not +-- already exist. +-- +-- The 'SourceRepo' values used in a single invocation of 'syncSourceRepos', +-- or used across a series of invocations with any local directory must refer +-- to the /same/ repository. That means it must be the same location but they +-- can differ in the branch, or tag or subdir. +-- +-- The reason to allow multiple related 'SourceRepo's is to allow for the +-- network or storage to be shared between different checkouts of the repo. +-- For example if a single repo contains multiple packages in different subdirs +-- and in some project it may make sense to use a different state of the repo +-- for one subdir compared to another. +-- +syncSourceRepos :: Verbosity + -> VCS ConfiguredProgram + -> [(SourceRepo, FilePath)] + -> Rebuild () +syncSourceRepos _verbosity _vcs _repos = undefined + + +{- +-- | Given a set of possible VCSs, and a set of possible source +-- repositories, find a repository that is both 1) likely to be specific to +-- this source version and 2) is supported by the local machine. +findVcsCloneInvocation :: Map RepoType (VCS ConfiguredProgram) + -> [SourceRepo] + -> Maybe RepoKind + -> FilePath + -> Maybe [ProgramInvocation] +findVcsCloneInvocation vcss repos maybeKind destdir = + listToMaybe + [ invocations + -- Sort repositories by kind, from This to Head to Unknown. Repositories + -- with equivalent kinds are selected based on the order they appear in + -- the Cabal description file. + | repo <- sortBy (comparing thisFirst) repos + -- If the user has specified the repo kind, filter out the repositories + -- they're not interested in. + , maybe True (repoKind repo ==) maybeKind + , Just invocations <- [repoCloneCmds repo] + ] + where + thisFirst :: SourceRepo -> Int + thisFirst r = case repoKind r of + RepoThis -> 0 + RepoHead -> case repoTag r of + -- If the type is 'head' but the author specified a tag, they + -- probably meant to create a 'this' repository but screwed up. + Just _ -> 0 + Nothing -> 1 + RepoKindUnknown _ -> 2 + + repoCloneCmds :: SourceRepo -> Maybe [ProgramInvocation] + repoCloneCmds repo = do + rtype <- repoType repo + srcurl <- repoLocation repo + vcs <- Map.lookup rtype vcss + return (vcsCloneRepo vcs (vcsProgram vcs) repo srcurl destdir) +-} + +-- ------------------------------------------------------------ +-- * The various VCS drivers +-- ------------------------------------------------------------ + +-- | VCS driver for Bazaar. +-- +vcsBzr :: VCS Program +vcsBzr = + VCS { + vcsRepoType = Bazaar, + vcsProgram = bzrProgram, + vcsCloneRepo, + vcsSyncRepos + } + where + vcsCloneRepo :: Verbosity + -> ConfiguredProgram + -> SourceRepo + -> FilePath + -> FilePath + -> [ProgramInvocation] + vcsCloneRepo verbosity prog repo srcurl destdir = + [ programInvocation prog + ([branchCmd, srcurl, destdir] ++ tagArgs ++ verboseArg) ] + where + -- The @get@ command was deprecated in version 2.4 in favour of + -- the alias @branch@ + branchCmd | programVersion prog >= Just (mkVersion [2,4]) + = "branch" + | otherwise = "get" + + tagArgs = case repoTag repo of + Nothing -> [] + Just tag -> ["-r", "tag:" ++ tag] + verboseArg = [ "--quiet" | verbosity < Verbosity.normal ] + + vcsSyncRepos :: Verbosity -> ConfiguredProgram + -> [(SourceRepo, FilePath)] -> IO [MonitorFilePath] + vcsSyncRepos _v _p _rs = fail "sync repo not yet supported for bzr" + +bzrProgram :: Program +bzrProgram = (simpleProgram "bzr") { + programFindVersion = findProgramVersion "--version" $ \str -> + case words str of + -- "Bazaar (bzr) 2.6.0\n ... lots of extra stuff" + (_:_:ver:_) -> ver + _ -> "" + } + + +-- | VCS driver for Darcs. +-- +vcsDarcs :: VCS Program +vcsDarcs = + VCS { + vcsRepoType = Darcs, + vcsProgram = darcsProgram, + vcsCloneRepo, + vcsSyncRepos + } + where + vcsCloneRepo :: Verbosity + -> ConfiguredProgram + -> SourceRepo + -> FilePath + -> FilePath + -> [ProgramInvocation] + vcsCloneRepo verbosity prog repo srcurl destdir = + [ programInvocation prog cloneArgs ] + where + cloneArgs = [cloneCmd, srcurl, destdir] ++ tagArgs ++ verboseArg + -- At some point the @clone@ command was introduced as an alias for + -- @get@, and @clone@ seems to be the recommended one now. + cloneCmd | programVersion prog >= Just (mkVersion [2,8]) + = "clone" + | otherwise = "get" + tagArgs = case repoTag repo of + Nothing -> [] + Just tag -> ["-t", tag] + verboseArg = [ "--quiet" | verbosity < Verbosity.normal ] + + vcsSyncRepos :: Verbosity -> ConfiguredProgram + -> [(SourceRepo, FilePath)] -> IO [MonitorFilePath] + vcsSyncRepos _v _p _rs = fail "sync repo not yet supported for darcs" + +darcsProgram :: Program +darcsProgram = (simpleProgram "darcs") { + programFindVersion = findProgramVersion "--version" $ \str -> + case words str of + -- "2.8.5 (release)" + (ver:_) -> ver + _ -> "" + } + + +-- | VCS driver for Git. +-- +vcsGit :: VCS Program +vcsGit = + VCS { + vcsRepoType = Git, + vcsProgram = gitProgram, + vcsCloneRepo, + vcsSyncRepos + } + where + vcsCloneRepo :: Verbosity + -> ConfiguredProgram + -> SourceRepo + -> FilePath + -> FilePath + -> [ProgramInvocation] + vcsCloneRepo verbosity prog repo srcurl destdir = + [ programInvocation prog cloneArgs ] + -- And if there's a tag, we have to do that in a second step: + ++ [ (programInvocation prog (checkoutArgs tag)) { + progInvokeCwd = Just destdir + } + | tag <- maybeToList (repoTag repo) ] + where + cloneArgs = ["clone", srcurl, destdir] + ++ branchArgs ++ verboseArg + branchArgs = case repoBranch repo of + Just b -> ["--branch", b] + Nothing -> [] + checkoutArgs tag = ["checkout", tag] ++ verboseArg + verboseArg = [ "--quiet" | verbosity < Verbosity.normal ] + + vcsSyncRepos :: Verbosity + -> ConfiguredProgram + -> [(SourceRepo, FilePath)] + -> IO [MonitorFilePath] + vcsSyncRepos _ _ [] = return [] + vcsSyncRepos verbosity gitProg + ((primaryRepo, primaryLocalDir) : secondaryRepos) = do + + vcsSyncRepo verbosity gitProg primaryRepo primaryLocalDir Nothing + sequence_ + [ vcsSyncRepo verbosity gitProg repo localDir (Just primaryLocalDir) + | (repo, localDir) <- secondaryRepos ] + return [ monitorDirectoryExistence dir + | dir <- (primaryLocalDir : map snd secondaryRepos) ] + + vcsSyncRepo verbosity gitProg SourceRepo{..} localDir peer = do + exists <- doesDirectoryExist localDir + if exists + then git localDir ["fetch"] + else git (takeDirectory localDir) cloneArgs + git localDir checkoutArgs + where + git :: FilePath -> [String] -> IO () + git cwd args = runProgramInvocation verbosity $ + (programInvocation gitProg args) { + progInvokeCwd = Just cwd + } + + cloneArgs = ["clone", "--no-checkout", loc, localDir] + ++ case peer of + Nothing -> [] + Just peerLocalDir -> ["--reference", peerLocalDir] + ++ verboseArg + where Just loc = repoLocation + checkoutArgs = [ "checkout", "--detach", "--force" + , checkoutTarget ] ++ verboseArg + checkoutTarget = fromMaybe "HEAD" (repoBranch `mplus` repoTag) + verboseArg = [ "--quiet" | verbosity < Verbosity.normal ] + +gitProgram :: Program +gitProgram = (simpleProgram "git") { + programFindVersion = findProgramVersion "--version" $ \str -> + case words str of + -- "git version 2.5.5" + (_:_:ver:_) -> ver + _ -> "" + } + + +-- | VCS driver for Mercurial. +-- +vcsHg :: VCS Program +vcsHg = + VCS { + vcsRepoType = Mercurial, + vcsProgram = hgProgram, + vcsCloneRepo, + vcsSyncRepos + } + where + vcsCloneRepo :: Verbosity + -> ConfiguredProgram + -> SourceRepo + -> FilePath + -> FilePath + -> [ProgramInvocation] + vcsCloneRepo verbosity prog repo srcurl destdir = + [ programInvocation prog cloneArgs ] + where + cloneArgs = ["clone", srcurl, destdir] + ++ branchArgs ++ tagArgs ++ verboseArg + branchArgs = case repoBranch repo of + Just b -> ["--branch", b] + Nothing -> [] + tagArgs = case repoTag repo of + Just t -> ["--rev", t] + Nothing -> [] + verboseArg = [ "--quiet" | verbosity < Verbosity.normal ] + + vcsSyncRepos :: Verbosity + -> ConfiguredProgram + -> [(SourceRepo, FilePath)] + -> IO [MonitorFilePath] + vcsSyncRepos _v _p _rs = fail "sync repo not yet supported for hg" + +hgProgram :: Program +hgProgram = (simpleProgram "hg") { + programFindVersion = findProgramVersion "--version" $ \str -> + case words str of + -- Mercurial Distributed SCM (version 3.5.2)\n ... long message + (_:_:_:_:ver:_) -> takeWhile (\c -> Char.isDigit c || c == '.') ver + _ -> "" + } + + +-- | VCS driver for Subversion. +-- +vcsSvn :: VCS Program +vcsSvn = + VCS { + vcsRepoType = SVN, + vcsProgram = svnProgram, + vcsCloneRepo, + vcsSyncRepos + } + where + vcsCloneRepo :: Verbosity + -> ConfiguredProgram + -> SourceRepo + -> FilePath + -> FilePath + -> [ProgramInvocation] + vcsCloneRepo verbosity prog _repo srcurl destdir = + [ programInvocation prog checkoutArgs ] + where + checkoutArgs = ["checkout", srcurl, destdir] ++ verboseArg + verboseArg = [ "--quiet" | verbosity < Verbosity.normal ] + --TODO: branch or tag? + + vcsSyncRepos :: Verbosity + -> ConfiguredProgram + -> [(SourceRepo, FilePath)] + -> IO [MonitorFilePath] + vcsSyncRepos _v _p _rs = fail "sync repo not yet supported for svn" + +svnProgram :: Program +svnProgram = (simpleProgram "svn") { + programFindVersion = findProgramVersion "--version" $ \str -> + case words str of + -- svn, version 1.9.4 (r1740329)\n ... long message + (_:_:ver:_) -> ver + _ -> "" + } + diff --git a/cabal-install/cabal-install.cabal b/cabal-install/cabal-install.cabal index 7273d6ca0abba5cac59e36e22c305b44648411a3..c9f9b9df60f98f1001f9e197b40ca141c3857b67 100644 --- a/cabal-install/cabal-install.cabal +++ b/cabal-install/cabal-install.cabal @@ -234,6 +234,7 @@ library Distribution.Client.Utils Distribution.Client.Utils.Assertion Distribution.Client.Utils.Json + Distribution.Client.VCS Distribution.Client.Win32SelfUpgrade Distribution.Client.World Distribution.Solver.Compat.Prelude @@ -489,6 +490,7 @@ executable cabal Distribution.Client.Utils Distribution.Client.Utils.Assertion Distribution.Client.Utils.Json + Distribution.Client.VCS Distribution.Client.Win32SelfUpgrade Distribution.Client.World Distribution.Solver.Compat.Prelude @@ -572,6 +574,7 @@ executable cabal UnitTests.Distribution.Client.ArbitraryInstances UnitTests.Distribution.Client.FileMonitor + UnitTests.Distribution.Client.Get UnitTests.Distribution.Client.GZipUtils UnitTests.Distribution.Client.Glob UnitTests.Distribution.Client.IndexUtils.Timestamp @@ -584,6 +587,7 @@ executable cabal UnitTests.Distribution.Client.Tar UnitTests.Distribution.Client.Targets UnitTests.Distribution.Client.UserConfig + UnitTests.Distribution.Client.VCS UnitTests.Distribution.Solver.Modular.Builder UnitTests.Distribution.Solver.Modular.DSL UnitTests.Distribution.Solver.Modular.DSL.TestCaseUtils diff --git a/cabal-install/tests/UnitTests/Distribution/Client/Get.hs b/cabal-install/tests/UnitTests/Distribution/Client/Get.hs new file mode 100644 index 0000000000000000000000000000000000000000..f31fbb602c816e3468fe99ae70383106a92227c7 --- /dev/null +++ b/cabal-install/tests/UnitTests/Distribution/Client/Get.hs @@ -0,0 +1,222 @@ +{-# LANGUAGE ScopedTypeVariables, FlexibleContexts #-} +module UnitTests.Distribution.Client.Get (tests) where + +import Distribution.Client.Get + +import Distribution.Types.PackageId +import Distribution.Types.PackageName +import Distribution.Types.SourceRepo +import Distribution.Verbosity as Verbosity +import Distribution.Version +import Distribution.Simple.Utils + ( withTempDirectory ) + +import Control.Monad +import Control.Exception +import Data.Typeable +import System.FilePath +import System.Directory +import System.Exit +import System.IO.Error + +import Test.Tasty +import Test.Tasty.HUnit +import UnitTests.Options (RunNetworkTests (..)) + +tests :: [TestTree] +tests = + [ testGroup "forkPackages" + [ testCase "no repos" testNoRepos + , testCase "no repos of requested kind" testNoReposOfKind + , testCase "no repo type specified" testNoRepoType + , testCase "unsupported repo type" testUnsupportedRepoType + , testCase "no repo location specified" testNoRepoLocation + , testCase "correct repo kind selection" testSelectRepoKind + , testCase "repo destination exists" testRepoDestinationExists + , testCase "git fetch failure" testGitFetchFailed + ] + , askOption $ \(RunNetworkTests doRunNetTests) -> + testGroup "forkPackages, network tests" $ + includeTestsIf doRunNetTests $ + [ testCase "git clone" testNetworkGitClone + ] + ] + where + includeTestsIf True xs = xs + includeTestsIf False _ = [] + + + +verbosity :: Verbosity +verbosity = Verbosity.silent -- verbose + +pkgidfoo :: PackageId +pkgidfoo = PackageIdentifier (mkPackageName "foo") (mkVersion [1,0]) + + +-- ------------------------------------------------------------ +-- * Unit tests +-- ------------------------------------------------------------ + +testNoRepos :: Assertion +testNoRepos = do + e <- assertException $ forkPackagesRepo verbosity "." Nothing pkgrepos + e @?= ForkExceptionNoSourceRepos pkgidfoo + where + pkgrepos = [(pkgidfoo, [])] + + +testNoReposOfKind :: Assertion +testNoReposOfKind = do + e <- assertException $ forkPackagesRepo verbosity "." repokind pkgrepos + e @?= ForkExceptionNoSourceReposOfKind pkgidfoo repokind + where + pkgrepos = [(pkgidfoo, [repo])] + repo = emptySourceRepo RepoHead + repokind = Just RepoThis + + +testNoRepoType :: Assertion +testNoRepoType = do + e <- assertException $ forkPackagesRepo verbosity "." Nothing pkgrepos + e @?= ForkExceptionNoRepoType pkgidfoo repo + where + pkgrepos = [(pkgidfoo, [repo])] + repo = emptySourceRepo RepoHead + + +testUnsupportedRepoType :: Assertion +testUnsupportedRepoType = do + e <- assertException $ forkPackagesRepo verbosity "." Nothing pkgrepos + e @?= ForkExceptionUnsupportedRepoType pkgidfoo repo repotype + where + pkgrepos = [(pkgidfoo, [repo])] + repo = (emptySourceRepo RepoHead) { + repoType = Just repotype + } + repotype = OtherRepoType "baz" + + +testNoRepoLocation :: Assertion +testNoRepoLocation = do + e <- assertException $ forkPackagesRepo verbosity "." Nothing pkgrepos + e @?= ForkExceptionNoRepoLocation pkgidfoo repo + where + pkgrepos = [(pkgidfoo, [repo])] + repo = (emptySourceRepo RepoHead) { + repoType = Just repotype + } + repotype = Darcs + + +testSelectRepoKind :: Assertion +testSelectRepoKind = + sequence_ + [ do e <- test requestedRepoType pkgrepos + e @?= ForkExceptionNoRepoType pkgidfoo expectedRepo + + e' <- test requestedRepoType (reverse pkgrepos) + e' @?= ForkExceptionNoRepoType pkgidfoo expectedRepo + | let test rt rs = assertException $ forkPackagesRepo verbosity "." rt rs + , (requestedRepoType, expectedRepo) <- cases + ] + where + pkgrepos = [(pkgidfoo, [repo1, repo2, repo3])] + repo1 = emptySourceRepo RepoThis + repo2 = emptySourceRepo RepoHead + repo3 = emptySourceRepo (RepoKindUnknown "bar") + cases = [ (Nothing, repo1) + , (Just RepoThis, repo1) + , (Just RepoHead, repo2) + , (Just (RepoKindUnknown "bar"), repo3) + ] + + +testRepoDestinationExists :: Assertion +testRepoDestinationExists = + withTempDirectory verbosity "." "repos" $ \tmpdir -> do + let pkgdir = tmpdir </> "foo" + createDirectory pkgdir + e1 <- assertException $ forkPackagesRepo verbosity tmpdir Nothing pkgrepos + e1 @?= ForkExceptionDestinationExists pkgidfoo pkgdir True {- isdir -} + + removeDirectory pkgdir + + writeFile pkgdir "" + e2 <- assertException $ forkPackagesRepo verbosity tmpdir Nothing pkgrepos + e2 @?= ForkExceptionDestinationExists pkgidfoo pkgdir False {- isfile -} + where + pkgrepos = [(pkgidfoo, [repo])] + repo = (emptySourceRepo RepoHead) { + repoType = Just Darcs, + repoLocation = Just "" + } + + +testGitFetchFailed :: Assertion +testGitFetchFailed = + withTempDirectory verbosity "." "repos" $ \tmpdir -> do + let srcdir = tmpdir </> "src" + repo = (emptySourceRepo RepoHead) { + repoType = Just Git, + repoLocation = Just srcdir + } + pkgrepos = [(pkgidfoo, [repo])] + e1 <- assertException $ forkPackagesRepo verbosity tmpdir Nothing pkgrepos + e1 @?= ForkExceptionFailedWithExitCode pkgidfoo repo "git" (ExitFailure 128) + + +testNetworkGitClone :: Assertion +testNetworkGitClone = + withTempDirectory verbosity "." "repos" $ \tmpdir -> do + let repo1 = (emptySourceRepo RepoHead) { + repoType = Just Git, + repoLocation = Just "https://github.com/haskell/zlib.git" + } + forkPackagesRepo verbosity tmpdir Nothing [(mkpkgid "zlib1", [repo1])] + assertFileContains (tmpdir </> "zlib1/zlib.cabal") ["name:", "zlib"] + + let repo2 = (emptySourceRepo RepoHead) { + repoType = Just Git, + repoLocation = Just (tmpdir </> "zlib1") + } + forkPackagesRepo verbosity tmpdir Nothing [(mkpkgid "zlib2", [repo2])] + assertFileContains (tmpdir </> "zlib2/zlib.cabal") ["name:", "zlib"] + + let repo3 = (emptySourceRepo RepoHead) { + repoType = Just Git, + repoLocation = Just (tmpdir </> "zlib1"), + repoTag = Just "0.5.0.0" + } + forkPackagesRepo verbosity tmpdir Nothing [(mkpkgid "zlib3", [repo3])] + assertFileContains (tmpdir </> "zlib3/zlib.cabal") ["version:", "0.5.0.0"] + where + mkpkgid nm = PackageIdentifier (mkPackageName nm) (mkVersion []) + + +-- ------------------------------------------------------------ +-- * HUnit utils +-- ------------------------------------------------------------ + +assertException :: forall e a. (Exception e, HasCallStack) => IO a -> IO e +assertException action = do + r <- try action + case r of + Left e -> return e + Right _ -> assertFailure $ "expected exception of type " + ++ show (typeOf (undefined :: e)) + + +-- | Expect that one line in a file matches exactly the given words (i.e. at +-- least insensitive to whitespace) +-- +assertFileContains :: HasCallStack => FilePath -> [String] -> Assertion +assertFileContains file expected = do + c <- readFile file `catch` \e -> + if isDoesNotExistError e + then assertFailure $ "expected a file to exist: " ++ file + else throwIO e + unless (expected `elem` map words (lines c)) $ + assertFailure $ "expected the file " ++ file ++ " to contain " + ++ show (take 100 expected) + diff --git a/cabal-install/tests/UnitTests/Distribution/Client/VCS.hs b/cabal-install/tests/UnitTests/Distribution/Client/VCS.hs new file mode 100644 index 0000000000000000000000000000000000000000..bc4b204af3fd82fe3fb2a0ad15258e908a970ce8 --- /dev/null +++ b/cabal-install/tests/UnitTests/Distribution/Client/VCS.hs @@ -0,0 +1,691 @@ +{-# LANGUAGE RecordWildCards, NamedFieldPuns #-} +module UnitTests.Distribution.Client.VCS {-(tests)-} where + +import Distribution.Client.VCS +import Distribution.Simple.Program +import Distribution.Simple.Utils + ( withTempDirectory ) +import Distribution.Verbosity as Verbosity +import Distribution.Types.SourceRepo + +import Data.List +import Data.Tuple +import qualified Data.Map as Map +import Data.Map (Map) +import qualified Data.Set as Set +import Data.Set (Set) +import Data.Char (isSpace) + +import Control.Monad +import qualified Control.Monad.State as State +import Control.Monad.State (StateT, liftIO, execStateT) +import Control.Exception +import Control.Concurrent (threadDelay) + +import System.IO +import System.FilePath +import System.Directory +import System.Random + +import Test.Tasty +import Test.Tasty.QuickCheck +import UnitTests.Distribution.Client.ArbitraryInstances + + +-- | These tests take the following approach: we generate a pure representation +-- of a repository plus a corresponding real repository, and then run various +-- test operations and compare the actual working state with the expected +-- working state. +-- +-- The first test simply checks that the test infrastructure works. It +-- constructs a repository on disk and then checks out every tag or commmit +-- and checks that the working state is the same as the pure representation. +-- +-- The second test works in a similar way but tests 'syncSourceRepos'. It +-- uses an arbitrary source repo and a set of (initially empty) destination +-- directories. It picks a number of tags or commits from the source repo and +-- synchronises the destination directories to those target states, and then +-- checks that the working state is as expected (given the pure representation). +-- +tests :: MTimeChange -> [TestTree] +tests _mtimeChange = + [ testGroup "check VCS test framework" + [ testProperty "git" prop_framework_git +-- , testProperty "darcs" (prop_framework_darcs mtimeChange) + ] + , testGroup "cloneSourceRepo" + [ testProperty "git" prop_cloneRepo_git +-- , testProperty "darcs" (prop_cloneRepo_darcs mtimeChange) + ] + , testGroup "syncSourceRepos" + [ testProperty "git" prop_syncRepos_git +-- , testProperty "darcs" (prop_syncRepos_darcs mtimeChange) + ] + ] + + +prop_framework_git :: BranchingRepoRecipe -> Property +prop_framework_git = + ioProperty + . prop_framework vcsGit vcsTestDriverGit + . WithBranchingSupport + +prop_framework_darcs :: MTimeChange -> NonBranchingRepoRecipe -> Property +prop_framework_darcs mtimeChange = + ioProperty + . prop_framework vcsDarcs (vcsTestDriverDarcs mtimeChange) + . WithoutBranchingSupport + +prop_cloneRepo_git :: BranchingRepoRecipe -> Property +prop_cloneRepo_git = + ioProperty + . prop_cloneRepo vcsGit vcsTestDriverGit + . WithBranchingSupport + +prop_cloneRepo_darcs :: MTimeChange + -> NonBranchingRepoRecipe -> Property +prop_cloneRepo_darcs mtimeChange = + ioProperty + . prop_cloneRepo vcsDarcs (vcsTestDriverDarcs mtimeChange) + . WithoutBranchingSupport + +prop_syncRepos_git :: RepoDirSet -> SyncTargetIterations -> PrngSeed + -> BranchingRepoRecipe -> Property +prop_syncRepos_git destRepoDirs syncTargetSetIterations seed = + ioProperty + . prop_syncRepos vcsGit vcsTestDriverGit + destRepoDirs syncTargetSetIterations seed + . WithBranchingSupport + +prop_syncRepos_darcs :: MTimeChange + -> RepoDirSet -> SyncTargetIterations -> PrngSeed + -> NonBranchingRepoRecipe -> Property +prop_syncRepos_darcs mtimeChange destRepoDirs syncTargetSetIterations seed = + ioProperty + . prop_syncRepos vcsDarcs (vcsTestDriverDarcs mtimeChange) + destRepoDirs syncTargetSetIterations seed + . WithoutBranchingSupport + + +-- ------------------------------------------------------------ +-- * General test setup +-- ------------------------------------------------------------ + +testSetup :: VCS Program + -> (Verbosity -> VCS ConfiguredProgram + -> FilePath -> VCSTestDriver) + -> RepoRecipe + -> (VCSTestDriver -> FilePath -> RepoState -> IO a) + -> IO a +testSetup vcs mkVCSTestDriver repoRecipe theTest = do + -- test setup + Just vcs' <- configureVCS verbosity vcs + withTestDir $ \tmpdir -> do + let srcRepoPath = tmpdir </> "src" + vcsDriver = mkVCSTestDriver verbosity vcs' srcRepoPath + repoState <- createRepo vcsDriver repoRecipe + + -- actual test + theTest vcsDriver tmpdir repoState + where + verbosity = silent + +-- ------------------------------------------------------------ +-- * Test 1: VCS infrastructure +-- ------------------------------------------------------------ + +-- | This test simply checks that the test infrastructure works. It constructs +-- a repository on disk and then checks out every tag or commit and checks that +-- the working state is the same as the pure representation. +-- +prop_framework :: VCS Program + -> (Verbosity -> VCS ConfiguredProgram + -> FilePath -> VCSTestDriver) + -> RepoRecipe + -> IO () +prop_framework vcs mkVCSTestDriver repoRecipe = + testSetup vcs mkVCSTestDriver repoRecipe $ \vcsDriver tmpdir repoState -> + mapM_ (checkAtTag vcsDriver tmpdir) (Map.toList (allTags repoState)) + where + -- Check for any given tag/commit in the 'RepoState' that the working state + -- matches the actual working state from the repository at that tag/commit. + checkAtTag VCSTestDriver {..} tmpdir (tagname, expectedState) = + case vcsCheckoutTag of + -- We handle two cases: inplace checkouts for VCSs that support it + -- (e.g. git) and separate dir otherwise (e.g. darcs) + Left checkoutInplace -> do + checkoutInplace tagname + checkExpectedWorkingState vcsIgnoreFiles vcsRepoRoot expectedState + + Right checkoutCloneTo -> do + checkoutCloneTo tagname destRepoPath + checkExpectedWorkingState vcsIgnoreFiles destRepoPath expectedState + removeDirectoryRecursive destRepoPath + where + destRepoPath = tmpdir </> "dest" + + +-- ------------------------------------------------------------ +-- * Test 2: 'cloneSourceRepo' +-- ------------------------------------------------------------ + +prop_cloneRepo :: VCS Program + -> (Verbosity -> VCS ConfiguredProgram + -> FilePath -> VCSTestDriver) + -> RepoRecipe + -> IO () +prop_cloneRepo vcs mkVCSTestDriver repoRecipe = + testSetup vcs mkVCSTestDriver repoRecipe $ \vcsDriver tmpdir repoState -> + mapM_ (checkAtTag vcsDriver tmpdir) (Map.toList (allTags repoState)) + where + checkAtTag VCSTestDriver{..} tmpdir (tagname, expectedState) = do + cloneSourceRepo verbosity vcsVCS repo vcsRepoRoot destRepoPath + checkExpectedWorkingState vcsIgnoreFiles destRepoPath expectedState + removeDirectoryRecursive destRepoPath + where + destRepoPath = tmpdir </> "dest" + repo = (emptySourceRepo RepoThis) { + repoType = Just (vcsRepoType vcsVCS), + repoLocation = Just vcsRepoRoot, + repoTag = Just tagname + } + verbosity = silent + + +-- ------------------------------------------------------------ +-- * Test 3: 'syncSourceRepos' +-- ------------------------------------------------------------ + +newtype RepoDirSet = RepoDirSet Int deriving Show +newtype SyncTargetIterations = SyncTargetIterations Int deriving Show +newtype PrngSeed = PrngSeed Int deriving Show + +prop_syncRepos :: VCS Program + -> (Verbosity -> VCS ConfiguredProgram + -> FilePath -> VCSTestDriver) + -> RepoDirSet + -> SyncTargetIterations + -> PrngSeed + -> RepoRecipe + -> IO () +prop_syncRepos vcs mkVCSTestDriver + repoDirs syncTargetSetIterations seed repoRecipe = + testSetup vcs mkVCSTestDriver repoRecipe $ \vcsDriver tmpdir repoState -> + let srcRepoPath = vcsRepoRoot vcsDriver + destRepoPaths = map (tmpdir </>) (getRepoDirs repoDirs) + in checkSyncRepos verbosity vcsDriver repoState + srcRepoPath destRepoPaths + syncTargetSetIterations seed + where + verbosity = silent + + getRepoDirs :: RepoDirSet -> [FilePath] + getRepoDirs (RepoDirSet n) = + [ "dest" ++ show i | i <- [1..n] ] + + +-- | The purpose of this test is to check that irrespective of the local cached +-- repo dir we can sync it to an arbitrary target state. So we do that by +-- syncing each target dir to a sequence of target states without cleaning it +-- in between. +-- +-- One slight complication is that 'syncSourceRepos' takes a whole list of +-- target dirs to sync in one go (to allow for sharing). So we must actually +-- generate and sync to a sequence of list of target repo states. +-- +-- So, given a source repo dir, the corresponding 'RepoState' and a number of +-- target repo dirs, pick a sequence of (lists of) sync targets from the +-- 'RepoState' and syncronise the target dirs with those targets, checking for +-- each one that the actual working state matches the expected repo state. +-- +checkSyncRepos + :: Verbosity + -> VCSTestDriver + -> RepoState + -> FilePath + -> [FilePath] + -> SyncTargetIterations + -> PrngSeed + -> IO () +checkSyncRepos verbosity VCSTestDriver { vcsVCS = vcs, vcsIgnoreFiles } + repoState srcRepoPath destRepoPath + (SyncTargetIterations syncTargetSetIterations) (PrngSeed seed) = + mapM_ checkSyncTargetSet syncTargetSets + where + checkSyncTargetSet :: [(SourceRepo, FilePath, RepoWorkingState)] -> IO () + checkSyncTargetSet syncTargets = do + _ <- vcsSyncRepos vcs verbosity (vcsProgram vcs) + [ (repo, repoPath) + | (repo, repoPath, _) <- syncTargets ] + sequence_ + [ checkExpectedWorkingState vcsIgnoreFiles repoPath workingState + | (_, repoPath, workingState) <- syncTargets ] + + syncTargetSets = take syncTargetSetIterations + $ pickSyncTargetSets (vcsRepoType vcs) repoState + srcRepoPath destRepoPath + (mkStdGen seed) + +pickSyncTargetSets :: RepoType -> RepoState + -> FilePath -> [FilePath] + -> StdGen + -> [[(SourceRepo, FilePath, RepoWorkingState)]] +pickSyncTargetSets repoType repoState srcRepoPath dstReposPath = + assert (Map.size (allTags repoState) > 0) $ + unfoldr (Just . swap . pickSyncTargetSet) + where + pickSyncTargetSet :: Rand [(SourceRepo, FilePath, RepoWorkingState)] + pickSyncTargetSet = flip (mapAccumL (flip pickSyncTarget)) dstReposPath + + pickSyncTarget :: FilePath -> Rand (SourceRepo, FilePath, RepoWorkingState) + pickSyncTarget destRepoPath prng = + (prng', (repo, destRepoPath, workingState)) + where + repo = (emptySourceRepo RepoThis) { + repoType = Just repoType, + repoLocation = Just srcRepoPath, + repoTag = Just tag + } + (tag, workingState) = Map.elemAt tagIdx (allTags repoState) + (tagIdx, prng') = randomR (0, Map.size (allTags repoState) - 1) prng + +type Rand a = StdGen -> (StdGen, a) + +instance Arbitrary RepoDirSet where + arbitrary = + sized $ \n -> oneof $ [ RepoDirSet <$> pure 1 ] + ++ [ RepoDirSet <$> choose (2,5) | n >= 3 ] + shrink (RepoDirSet n) = + [ RepoDirSet i | i <- shrink n, i > 0 ] + +instance Arbitrary SyncTargetIterations where + arbitrary = + sized $ \n -> SyncTargetIterations <$> elements [ 1 .. min 20 (n + 1) ] + shrink (SyncTargetIterations n) = + [ SyncTargetIterations i | i <- shrink n, i > 0 ] + +instance Arbitrary PrngSeed where + arbitrary = PrngSeed <$> arbitraryBoundedRandom + + +-- ------------------------------------------------------------ +-- * Instructions for constructing repositories +-- ------------------------------------------------------------ + +-- These instructions for constructing a repository can be interpreted in two +-- ways: to make a pure representation of repository state, and to execute +-- VCS commands to make a repository on-disk. + +data FileUpdate = FileUpdate FilePath String deriving Show +data Commit = Commit [FileUpdate] deriving Show +data TaggedCommits = TaggedCommits TagName [Commit] deriving Show +data BranchCommits = BranchCommits BranchName [Commit] deriving Show + +type BranchName = String +type TagName = String + +-- | Instructions to make a repository without branches, for VCSs that do not +-- support branches (e.g. darcs). +newtype NonBranchingRepoRecipe = NonBranchingRepoRecipe [TaggedCommits] + deriving Show + +-- | Instructions to make a repository with branches, for VCSs that do +-- support branches (e.g. git). +newtype BranchingRepoRecipe = BranchingRepoRecipe + [Either TaggedCommits BranchCommits] + deriving Show + +data RepoRecipe = WithBranchingSupport BranchingRepoRecipe + | WithoutBranchingSupport NonBranchingRepoRecipe + +-- --------------------------------------------------------------------------- +-- Arbitrary instances for them + +instance Arbitrary FileUpdate where + arbitrary = FileUpdate <$> genFileName <*> genFileContent + where + genFileName = (\c -> "file/" ++ [c]) <$> choose ('A', 'E') + genFileContent = vectorOf 10 (choose ('#', '~')) + +instance Arbitrary Commit where + arbitrary = Commit <$> shortListOf1 5 arbitrary + shrink (Commit writes) = Commit <$> filter (not . null) (shrink writes) + +instance Arbitrary TaggedCommits where + arbitrary = TaggedCommits <$> genTagName <*> shortListOf1 5 arbitrary + where + genTagName = ("tag_" ++) <$> shortListOf1 5 (choose ('A', 'Z')) + shrink (TaggedCommits tag commits) = + TaggedCommits tag <$> filter (not . null) (shrink commits) + +instance Arbitrary BranchCommits where + arbitrary = BranchCommits <$> genBranchName <*> shortListOf1 5 arbitrary + where + genBranchName = + sized $ \n -> + (\c -> "branch_" ++ [c]) <$> elements (take (max 1 n) ['A'..'E']) + + shrink (BranchCommits branch commits) = + BranchCommits branch <$> filter (not . null) (shrink commits) + +instance Arbitrary NonBranchingRepoRecipe where + arbitrary = NonBranchingRepoRecipe <$> shortListOf1 15 arbitrary + shrink (NonBranchingRepoRecipe xs) = + NonBranchingRepoRecipe <$> filter (not . null) (shrink xs) + +instance Arbitrary BranchingRepoRecipe where + arbitrary = BranchingRepoRecipe <$> shortListOf1 15 taggedOrBranch + where + taggedOrBranch = frequency [ (3, Left <$> arbitrary) + , (1, Right <$> arbitrary) + ] + shrink (BranchingRepoRecipe xs) = + BranchingRepoRecipe <$> filter (not . null) (shrink xs) + + +-- ------------------------------------------------------------ +-- * A pure model of repository state +-- ------------------------------------------------------------ + +-- | The full state of a repository. In particular it records the full working +-- state for every tag. +-- +-- This is also the interpreter state for executing a 'RepoRecipe'. +-- +-- This allows us to compare expected working states with the actual files in +-- the working directory of a repository. See 'checkExpectedWorkingState'. +-- +data RepoState = + RepoState { + currentBranch :: BranchName, + currentWorking :: RepoWorkingState, + allTags :: Map TagOrCommitId RepoWorkingState, + allBranches :: Map BranchName RepoWorkingState + } + deriving Show + +type RepoWorkingState = Map FilePath String +type CommitId = String +type TagOrCommitId = String + + +------------------------------------------------------------------------------ +-- Functions used to interpret instructions for constructing repositories + +initialRepoState :: RepoState +initialRepoState = + RepoState { + currentBranch = "branch_master", + currentWorking = Map.empty, + allTags = Map.empty, + allBranches = Map.empty + } + +updateFile :: FilePath -> String -> RepoState -> RepoState +updateFile filename content state@RepoState{currentWorking} = + state { currentWorking = Map.insert filename content currentWorking } + +addTagOrCommit :: TagOrCommitId -> RepoState -> RepoState +addTagOrCommit commit state@RepoState{currentWorking, allTags} = + state { allTags = Map.insert commit currentWorking allTags } + +switchBranch :: BranchName -> RepoState -> RepoState +switchBranch branch state@RepoState{currentWorking, currentBranch, allBranches} = + -- Use updated allBranches to cover case of switching to the same branch + let allBranches' = Map.insert currentBranch currentWorking allBranches in + state { + currentBranch = branch, + currentWorking = case Map.lookup branch allBranches' of + Just working -> working + -- otherwise we're creating a new branch, which starts + -- from our current branch state + Nothing -> currentWorking, + allBranches = allBranches' + } + + +-- ------------------------------------------------------------ +-- * Comparing on-disk with expected 'RepoWorkingState' +-- ------------------------------------------------------------ + +-- | Compare expected working states with the actual files in +-- the working directory of a repository. +-- +checkExpectedWorkingState :: Set FilePath + -> FilePath -> RepoWorkingState -> IO () +checkExpectedWorkingState ignore repoPath expectedState = do + currentState <- getCurrentWorkingState ignore repoPath + unless (currentState == expectedState) $ + throwIO (WorkingStateMismatch expectedState currentState) + +data WorkingStateMismatch = + WorkingStateMismatch RepoWorkingState -- expected + RepoWorkingState -- actual + deriving Show + +instance Exception WorkingStateMismatch + +getCurrentWorkingState :: Set FilePath -> FilePath -> IO RepoWorkingState +getCurrentWorkingState ignore repoRoot = do + entries <- getDirectoryContentsRecursive ignore repoRoot "" + Map.fromList <$> mapM getFileEntry + [ file | (file, isDir) <- entries, not isDir ] + where + getFileEntry name = + withBinaryFile (repoRoot </> name) ReadMode $ \h -> do + str <- hGetContents h + _ <- evaluate (length str) + return (name, str) + +getDirectoryContentsRecursive :: Set FilePath -> FilePath -> FilePath + -> IO [(FilePath, Bool)] +getDirectoryContentsRecursive ignore dir0 dir = do + entries <- getDirectoryContents (dir0 </> dir) + entries' <- sequence + [ do isdir <- doesDirectoryExist (dir0 </> dir </> entry) + return (dir </> entry, isdir) + | entry <- entries + , not (isPrefixOf "." entry) + , (dir </> entry) `Set.notMember` ignore + ] + let subdirs = [ d | (d, True) <- entries' ] + subdirEntries <- mapM (getDirectoryContentsRecursive ignore dir0) subdirs + return (concat (entries' : subdirEntries)) + + +-- ------------------------------------------------------------ +-- * Executing instructions to make on-disk VCS repos +-- ------------------------------------------------------------ + +-- | Execute the instructions in a 'RepoRecipe' using the given 'VCSTestDriver' +-- to make an on-disk repository. +-- +-- This also returns a 'RepoState'. This is done as part of construction to +-- support VCSs like git that have commit ids, so that those commit ids can be +-- included in the 'RepoState's 'allTags' set. +-- +createRepo :: VCSTestDriver -> RepoRecipe -> IO RepoState +createRepo vcsDriver@VCSTestDriver{vcsRepoRoot, vcsInit} recipe = do + createDirectory vcsRepoRoot + createDirectory (vcsRepoRoot </> "file") + vcsInit + execStateT createRepoAction initialRepoState + where + createRepoAction :: StateT RepoState IO () + createRepoAction = case recipe of + WithoutBranchingSupport r -> execNonBranchingRepoRecipe vcsDriver r + WithBranchingSupport r -> execBranchingRepoRecipe vcsDriver r + +type CreateRepoAction a = VCSTestDriver -> a -> StateT RepoState IO () + +execNonBranchingRepoRecipe :: CreateRepoAction NonBranchingRepoRecipe +execNonBranchingRepoRecipe vcsDriver (NonBranchingRepoRecipe taggedCommits) = + mapM_ (execTaggdCommits vcsDriver) taggedCommits + +execBranchingRepoRecipe :: CreateRepoAction BranchingRepoRecipe +execBranchingRepoRecipe vcsDriver (BranchingRepoRecipe taggedCommits) = + mapM_ (either (execTaggdCommits vcsDriver) + (execBranchCommits vcsDriver)) + taggedCommits + +execBranchCommits :: CreateRepoAction BranchCommits +execBranchCommits vcsDriver@VCSTestDriver{vcsSwitchBranch} + (BranchCommits branch commits) = do + mapM_ (execCommit vcsDriver) commits + -- add commits and then switch branch + State.modify (switchBranch branch) + state <- State.get -- repo state after the commits and branch switch + liftIO $ vcsSwitchBranch state branch + + -- It may seem odd that we add commits on the existing branch and then + -- switch branch. In part this is because git cannot branch from an empty + -- repo state, it complains that the master branch doesn't exist yet. + +execTaggdCommits :: CreateRepoAction TaggedCommits +execTaggdCommits vcsDriver@VCSTestDriver{vcsTagState} + (TaggedCommits tagname commits) = do + mapM_ (execCommit vcsDriver) commits + -- add commits then tag + state <- State.get -- repo state after the commits + liftIO $ vcsTagState state tagname + State.modify (addTagOrCommit tagname) + +execCommit :: CreateRepoAction Commit +execCommit vcsDriver@VCSTestDriver{..} (Commit fileUpdates) = do + mapM_ (execFileUpdate vcsDriver) fileUpdates + state <- State.get -- existing state, not updated + mcommit <- liftIO $ vcsCommitChanges state + State.modify (maybe id addTagOrCommit mcommit) + +execFileUpdate :: CreateRepoAction FileUpdate +execFileUpdate VCSTestDriver{..} (FileUpdate filename content) = do + liftIO $ writeFile (vcsRepoRoot </> filename) content + state <- State.get -- existing state, not updated + liftIO $ vcsAddFile state filename + State.modify (updateFile filename content) + + +-- ------------------------------------------------------------ +-- * VCSTestDriver for various VCSs +-- ------------------------------------------------------------ + +-- | Extends 'VCS' with extra methods to construct a repository. Used by +-- 'createRepo'. +-- +-- Several of the methods are allowed to rely on the current 'RepoState' +-- because some VCSs need different commands for initial vs later actions +-- (like adding a file to the tracked set, or creating a new branch). +-- +-- The driver instance knows the particular repo directory. +-- +data VCSTestDriver = VCSTestDriver { + vcsVCS :: VCS ConfiguredProgram, + vcsRepoRoot :: FilePath, + vcsIgnoreFiles :: Set FilePath, + vcsInit :: IO (), + vcsAddFile :: RepoState -> FilePath -> IO (), + vcsCommitChanges :: RepoState -> IO (Maybe CommitId), + vcsTagState :: RepoState -> TagName -> IO (), + vcsSwitchBranch :: RepoState -> BranchName -> IO (), + vcsCheckoutTag :: Either (TagName -> IO ()) + (TagName -> FilePath -> IO ()) + } + + +vcsTestDriverGit :: Verbosity -> VCS ConfiguredProgram + -> FilePath -> VCSTestDriver +vcsTestDriverGit verbosity vcs repoRoot = + VCSTestDriver { + vcsVCS = vcs + + , vcsRepoRoot = repoRoot + + , vcsIgnoreFiles = Set.empty + + , vcsInit = + git $ ["init"] ++ verboseArg + + , vcsAddFile = \_ filename -> + git ["add", filename] + + , vcsCommitChanges = \_state -> do + git $ ["commit", "--all", "--message=a patch", + "--author=Author <author@example.com>"] ++ verboseArg + commit <- git' ["log", "--format=%H", "-1"] + let commit' = takeWhile (not . isSpace) commit + return (Just commit') + + , vcsTagState = \_ tagname -> + git ["tag", "--force", tagname] + + , vcsSwitchBranch = \RepoState{allBranches} branchname -> do + unless (branchname `Map.member` allBranches) $ + git ["branch", branchname] + git $ ["checkout", branchname] ++ verboseArg + + , vcsCheckoutTag = Left $ \tagname -> + git $ ["checkout", "--detach", "--force", tagname] ++ verboseArg + } + where + gitInvocation args = (programInvocation (vcsProgram vcs) args) { + progInvokeCwd = Just repoRoot + } + git = runProgramInvocation verbosity . gitInvocation + git' = getProgramInvocationOutput verbosity . gitInvocation + verboseArg = [ "--quiet" | verbosity < Verbosity.normal ] + + +type MTimeChange = Int + +vcsTestDriverDarcs :: MTimeChange -> Verbosity -> VCS ConfiguredProgram + -> FilePath -> VCSTestDriver +vcsTestDriverDarcs mtimeChange verbosity vcs repoRoot = + VCSTestDriver { + vcsVCS = vcs + + , vcsRepoRoot = repoRoot + + , vcsIgnoreFiles = Set.singleton "_darcs" + + , vcsInit = + darcs ["initialize"] + + , vcsAddFile = \state filename -> do + threadDelay mtimeChange + unless (filename `Map.member` currentWorking state) $ + darcs ["add", filename] + -- Darcs's file change tracking relies on mtime changes, + -- so we have to be careful with doing stuff too quickly: + + , vcsCommitChanges = \_state -> do + threadDelay mtimeChange + darcs ["record", "--all", "--author=author", "--name=a patch"] + return Nothing + + , vcsTagState = \_ tagname -> + darcs ["tag", "--author=author", tagname] + + , vcsSwitchBranch = \_ _ -> + fail "vcsSwitchBranch: darcs does not support branches within a repo" + + , vcsCheckoutTag = Right $ \tagname dest -> + darcs ["clone", "--lazy", "--tag=^" ++ tagname ++ "$", ".", dest] + } + where + darcsInvocation args = (programInvocation (vcsProgram vcs) args) { + progInvokeCwd = Just repoRoot + } + darcs = runProgramInvocation verbosity . darcsInvocation + + +-- ------------------------------------------------------------ +-- * Utils +-- ------------------------------------------------------------ + +withTestDir :: (FilePath -> IO a) -> IO a +withTestDir action = do + systmpdir <- getTemporaryDirectory + withTempDirectory verbosity systmpdir "vcstest" action + where + verbosity = silent + diff --git a/cabal-install/tests/UnitTests/Options.hs b/cabal-install/tests/UnitTests/Options.hs index 58510d3746340fb4a14386118df5800696689790..1edce0355426a1ade192b42466eabcfcc5bbb5d2 100644 --- a/cabal-install/tests/UnitTests/Options.hs +++ b/cabal-install/tests/UnitTests/Options.hs @@ -2,6 +2,7 @@ module UnitTests.Options ( OptionShowSolverLog(..) , OptionMtimeChangeDelay(..) + , RunNetworkTests(..) , extraOptions ) where @@ -18,6 +19,7 @@ extraOptions :: [OptionDescription] extraOptions = [ Option (Proxy :: Proxy OptionShowSolverLog) , Option (Proxy :: Proxy OptionMtimeChangeDelay) + , Option (Proxy :: Proxy RunNetworkTests) ] newtype OptionShowSolverLog = OptionShowSolverLog Bool @@ -25,7 +27,7 @@ newtype OptionShowSolverLog = OptionShowSolverLog Bool instance IsOption OptionShowSolverLog where defaultValue = OptionShowSolverLog False - parseValue = fmap OptionShowSolverLog . safeRead + parseValue = fmap OptionShowSolverLog . safeReadBool optionName = return "show-solver-log" optionHelp = return "Show full log from the solver" optionCLParser = flagCLParser Nothing (OptionShowSolverLog True) @@ -39,3 +41,12 @@ instance IsOption OptionMtimeChangeDelay where optionName = return "mtime-change-delay" optionHelp = return $ "How long to wait before attempting to detect" ++ "file modification, in microseconds" + +newtype RunNetworkTests = RunNetworkTests Bool + deriving Typeable + +instance IsOption RunNetworkTests where + defaultValue = RunNetworkTests True + parseValue = fmap RunNetworkTests . safeReadBool + optionName = return "run-network-tests" + optionHelp = return "Run tests that need network access (default true)."