vcs-ignore-0.1.0.0: test/Data/VCS/Ignore/Git/Internal/RepositorySpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Data.VCS.Ignore.Git.Internal.RepositorySpec (spec) where
import Control.Concurrent (
forkIO,
killThread,
newEmptyMVar,
putMVar,
readMVar,
takeMVar,
)
import Control.Exception (
IOException,
SomeException,
bracket_,
try,
)
import Control.Monad (replicateM)
import Data.Either (isLeft, isRight)
import Data.IORef (
atomicModifyIORef',
newIORef,
readIORef,
)
import Data.VCS.Ignore.Git.Internal.Pattern (
PatternGroup (..),
)
import Data.VCS.Ignore.Git.Internal.Repository
import Data.VCS.Ignore.Types (
GitError (..),
PathKind (..),
)
import System.Directory (
canonicalizePath,
createDirectoryIfMissing,
)
import System.Environment (
lookupEnv,
setEnv,
unsetEnv,
)
import System.FilePath (
pathSeparator,
takeDirectory,
(</>),
)
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
spec :: Spec
spec = do
describe "openGitRepository" $ do
repositoryExample "opens a repository without scanning nested rules" $ \root -> do
let nestedIgnore = root </> "nested" </> ".gitignore"
writeText nestedIgnore "old.txt\n"
repository <- openGitRepository root
writeText nestedIgnore "new.txt\n"
isIgnored repository RegularFile "nested/new.txt" `shouldReturn` True
isIgnored repository RegularFile "nested/old.txt" `shouldReturn` False
it "rejects a directory without Git metadata"
. withSystemTempDirectory "vcs-ignore-invalid"
$ \root ->
openGitRepository root `shouldThrow` isNotRepository
it "resolves gitfile and commondir metadata"
. withIsolatedEnvironment
$ \sandbox -> do
let root = sandbox </> "repo"
gitDirectory = sandbox </> "admin" </> "worktrees" </> "main"
commonDirectory = sandbox </> "admin" </> "common"
createDirectoryIfMissing True root
createDirectoryIfMissing True gitDirectory
createDirectoryIfMissing True $ commonDirectory </> "info"
writeText (root </> ".git") "gitdir: ../admin/worktrees/main\n"
writeText (gitDirectory </> "commondir") "../../common\n"
writeText (commonDirectory </> "info" </> "exclude") "from-info.txt\n"
repository <- openGitRepository root
canonicalGitDirectory <- canonicalizePath gitDirectory
canonicalCommon <- canonicalizePath commonDirectory
gitRepositoryGitDirectory repository `shouldBe` canonicalGitDirectory
gitRepositoryCommonDirectory repository `shouldBe` canonicalCommon
isIgnored repository RegularFile "from-info.txt" `shouldReturn` True
it "reports malformed gitfile metadata"
. withIsolatedEnvironment
$ \sandbox -> do
let root = sandbox </> "repo"
createDirectoryIfMissing True root
writeText (root </> ".git") "not a gitdir\n"
openGitRepository root `shouldThrow` isInvalidMetadata
it "recognizes in-tree gitdir and commondir paths as metadata"
. withIsolatedEnvironment
$ \sandbox -> do
let root = sandbox </> "repo"
gitDirectory = root </> ".metadata" </> "worktree"
commonDirectory = root </> ".metadata" </> "common"
createDirectoryIfMissing True gitDirectory
createDirectoryIfMissing True commonDirectory
writeText (root </> ".git") "gitdir: .metadata/worktree\n"
writeText (gitDirectory </> "commondir") "../common\n"
repository <- openGitRepository root
isRepositoryMetadata repository ".metadata/worktree" `shouldBe` True
isRepositoryMetadata repository ".metadata/worktree/index" `shouldBe` True
isRepositoryMetadata repository ".metadata/common" `shouldBe` True
isRepositoryMetadata repository ".metadata/working-file" `shouldBe` False
describe "findGitRepository" $ do
repositoryExample "finds the nearest enclosing repository" $ \root -> do
let nested = root </> "one" </> "two"
createDirectoryIfMissing True nested
(fmap repositoryRoot <$> findGitRepository nested)
`shouldReturnJustCanonicalPath` root
it "returns Nothing when no repository encloses the path"
. withIsolatedEnvironment
$ \sandbox ->
(fmap repositoryRoot <$> findGitRepository sandbox)
`shouldReturn` Nothing
describe "repository rule context" $ do
repositoryExample "applies global, info, root, and nested rules in order" $ \root -> do
let sandbox = takeDirectory root
writeText (sandbox </> "xdg" </> "git" </> "ignore") "*.cache\n"
writeText (root </> ".git" </> "info" </> "exclude") "*.log\n"
writeText (root </> ".gitignore") "!keep.log\n"
writeText (root </> "nested" </> ".gitignore") "!keep.cache\n"
repository <- openGitRepository root
isIgnored repository RegularFile "drop.cache" `shouldReturn` True
isIgnored repository RegularFile "drop.log" `shouldReturn` True
isIgnored repository RegularFile "keep.log" `shouldReturn` False
isIgnored repository RegularFile "nested/keep.cache" `shouldReturn` False
repositoryExample "caches each directory rule group after first use" $ \root -> do
let ignoreFile = root </> ".gitignore"
writeText ignoreFile "cached.txt\n"
repository <- openGitRepository root
isIgnored repository RegularFile "cached.txt" `shouldReturn` True
writeText ignoreFile ""
isIgnored repository RegularFile "cached.txt" `shouldReturn` True
repositoryExample "shares a cached rule load between concurrent queries" $ \root -> do
writeText (root </> ".gitignore") "shared.txt\n"
repository <- openGitRepository root
start <- newEmptyMVar
outputs <- replicateM 8 newEmptyMVar
mapM_
( \output -> do
_ <- forkIO $ do
readMVar start
isIgnored repository RegularFile "shared.txt" >>= putMVar output
pure ()
)
outputs
putMVar start ()
results <- mapM readMVar outputs
results `shouldBe` replicate 8 True
repositoryExample "retries a shared rule load after owner cancellation" $ \root -> do
repository <- openGitRepository root
calls <- newIORef (0 :: Int)
firstLoadStarted <- newEmptyMVar
blockFirstLoad <- newEmptyMVar
ownerResult <- newEmptyMVar
waiterResult <- newEmptyMVar
let loader _ = do
call <- atomicModifyIORef' calls $ \count -> (count + 1, count + 1)
if call == 1
then putMVar firstLoadStarted () >> takeMVar blockFirstLoad >> pure []
else pure []
owner <- forkIO $ do
result <- try @SomeException $ loadDirectoryRulesWith repository loader "."
putMVar ownerResult result
takeMVar firstLoadStarted
_ <- forkIO $ do
result <- try @SomeException $ loadDirectoryRulesWith repository loader "."
putMVar waiterResult result
killThread owner
firstResult <- takeMVar ownerResult
secondResult <- takeMVar waiterResult
firstResult `shouldSatisfy` isLeft
secondResult `shouldSatisfy` isRight
readIORef calls `shouldReturn` 2
repositoryExample "caches synchronous rule-loading failures" $ \root -> do
repository <- openGitRepository root
calls <- newIORef (0 :: Int)
let loader _ = do
atomicModifyIORef' calls $ \count -> (count + 1, ())
ioError $ userError "rule load failed"
firstResult <- try @IOException $ loadDirectoryRulesWith repository loader "."
secondResult <- try @IOException $ loadDirectoryRulesWith repository loader "."
firstResult `shouldSatisfy` isLeft
secondResult `shouldSatisfy` isLeft
readIORef calls `shouldReturn` 1
repositoryExample "does not load rules below an ignored parent" $ \root -> do
writeText (root </> ".gitignore") "build/\n"
writeText (root </> "build" </> ".gitignore") "!keep.txt\n"
repository <- openGitRepository root
isIgnored repository Directory "build" `shouldReturn` True
isIgnored repository RegularFile "build/keep.txt" `shouldReturn` True
repositoryExample "does not apply a directory's rules to itself" $ \root -> do
writeText (root </> "nested" </> ".gitignore") "nested/\nself.txt\n"
repository <- openGitRepository root
isIgnored repository Directory "nested" `shouldReturn` False
isIgnored repository RegularFile "nested/self.txt" `shouldReturn` True
repositoryExample "returns normalized prefixes for cached groups" $ \root -> do
repository <- openGitRepository root
rootGroups <- rootRuleContext repository
nestedGroup <- loadDirectoryRules repository "nested/deeper"
fmap patternGroupPrefix rootGroups `shouldBe` ["/", "/"]
patternGroupPrefix nestedGroup `shouldBe` "/nested/deeper/"
describe "repository paths" $ do
it "normalizes valid relative paths" $ do
validateRepositoryPath "" `shouldBe` Right "."
validateRepositoryPath "./one//two" `shouldBe` Right "one/two"
groupPrefix "." `shouldBe` "/"
groupPrefix "one/two" `shouldBe` "/one/two/"
it "rejects absolute and escaping paths" $ do
validateRepositoryPath "/outside" `shouldSatisfy` isLeft
validateRepositoryPath "../outside" `shouldSatisfy` isLeft
validateRepositoryPath "inside/../outside" `shouldSatisfy` isLeft
if pathSeparator == '\\'
then validateRepositoryPath "C:\\outside" `shouldSatisfy` isLeft
else validateRepositoryPath "C:\\outside" `shouldBe` Right "C:\\outside"
repositoryExample "always ignores root Git metadata only" $ \root -> do
repository <- openGitRepository root
isRepositoryMetadata repository ".git" `shouldBe` True
isRepositoryMetadata repository ".git/objects/value" `shouldBe` True
isRepositoryMetadata repository "nested/.git" `shouldBe` False
isIgnored repository Directory ".git" `shouldReturn` True
isIgnored repository RegularFile ".git/config" `shouldReturn` True
repositoryExample :: String -> (FilePath -> Expectation) -> Spec
repositoryExample description action =
it description . withIsolatedEnvironment $ \sandbox -> do
let root = sandbox </> "repo"
createDirectoryIfMissing True $ root </> ".git" </> "info"
action root
withIsolatedEnvironment :: (FilePath -> IO a) -> IO a
withIsolatedEnvironment action =
withSystemTempDirectory "vcs-ignore-repository" $ \sandbox ->
withEnvironment "XDG_CONFIG_HOME" (sandbox </> "xdg") $
action sandbox
withEnvironment :: String -> String -> IO a -> IO a
withEnvironment name value action = do
original <- lookupEnv name
bracket_ (setEnv name value) (restore original) action
where
restore Nothing = unsetEnv name
restore (Just original) = setEnv name original
writeText :: FilePath -> String -> IO ()
writeText path content = do
createDirectoryIfMissing True $ takeDirectory path
writeFile path content
isNotRepository :: GitError -> Bool
isNotRepository (NotGitRepository _) = True
isNotRepository _ = False
isInvalidMetadata :: GitError -> Bool
isInvalidMetadata (InvalidGitMetadata _ _) = True
isInvalidMetadata _ = False
shouldReturnJustCanonicalPath :: IO (Maybe FilePath) -> FilePath -> Expectation
shouldReturnJustCanonicalPath action expectedPath = do
expected <- canonicalizePath expectedPath
action `shouldReturn` Just expected