vcs-ignore-0.1.0.0: src/Data/VCS/Ignore/Git/Internal/Repository.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
-- |
-- Module : Data.VCS.Ignore.Git.Internal.Repository
-- Description : Lazy Git repository session and ignore queries
-- Copyright : (c) 2020-2026 Vaclav Svejcar
-- License : BSD-3-Clause
-- Maintainer : vaclav.svejcar@gmail.com
-- Stability : experimental
-- Portability : portable
module Data.VCS.Ignore.Git.Internal.Repository (
GitRepository (..),
openGitRepository,
findGitRepository,
repositoryRoot,
isIgnored,
rootRuleContext,
loadDirectoryRules,
loadDirectoryRulesWith,
isRepositoryMetadata,
validateRepositoryPath,
groupPrefix,
) where
import Control.Concurrent.MVar (
MVar,
modifyMVar,
modifyMVar_,
newEmptyMVar,
newMVar,
putMVar,
readMVar,
)
import Control.Exception (
AsyncException,
SomeException,
fromException,
mask,
throwIO,
try,
)
import Control.Monad (unless)
import qualified Data.Char as Char
import qualified Data.List as List
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, isJust)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import Data.VCS.Ignore.Git.Internal.Pattern (
Pattern,
PatternGroup (..),
evaluatePatternGroups,
loadPatternsFile,
)
import Data.VCS.Ignore.Types (
GitError (..),
PathKind (..),
)
import System.Directory (
XdgDirectory (XdgConfig),
canonicalizePath,
doesDirectoryExist,
doesFileExist,
getXdgDirectory,
makeAbsolute,
)
import System.FilePath (
isAbsolute,
makeRelative,
pathSeparator,
takeDirectory,
(</>),
)
import qualified System.FilePath.Posix as Posix
-- | An opened Git working tree. Repository-level rules are captured when the
-- session is opened, while working-tree @.gitignore@ files are loaded and
-- cached on first use.
data GitRepository = GitRepository
{ gitRepositoryRoot :: FilePath
, gitRepositoryGitDirectory :: FilePath
, gitRepositoryCommonDirectory :: FilePath
, gitRepositoryBasePatterns :: [Pattern]
, gitRepositoryRuleCache :: MVar (Map.Map FilePath RulePromise)
}
data RuleLoadResult
= RuleLoadFinished (Either SomeException PatternGroup)
| RuleLoadCancelled
type RulePromise = MVar RuleLoadResult
-- | Open a Git working tree without scanning its contents.
openGitRepository :: FilePath -> IO GitRepository
openGitRepository path = do
root <- makeAbsolute path >>= canonicalizePath
isDirectory <- doesDirectoryExist root
unless isDirectory (throwIO $ NotGitRepository root)
gitDirectory <- resolveGitDirectory root
commonDirectory <- resolveCommonDirectory gitDirectory
globalIgnore <- getXdgDirectory XdgConfig ("git" </> "ignore")
globalPatterns <- loadPatternsFile True globalIgnore
repositoryPatterns <-
loadPatternsFile True (commonDirectory </> "info" </> "exclude")
cache <- newMVar Map.empty
pure
GitRepository
{ gitRepositoryRoot = root
, gitRepositoryGitDirectory = gitDirectory
, gitRepositoryCommonDirectory = commonDirectory
, gitRepositoryBasePatterns = globalPatterns <> repositoryPatterns
, gitRepositoryRuleCache = cache
}
-- | Find and open the nearest enclosing Git working tree.
findGitRepository :: FilePath -> IO (Maybe GitRepository)
findGitRepository path = do
absolute <- makeAbsolute path
isDirectory <- doesDirectoryExist absolute
start <- canonicalizePath $ if isDirectory then absolute else takeDirectory absolute
go start
where
go directory = do
hasMetadata <- gitMetadataExists directory
let parent = takeDirectory directory
if hasMetadata
then Just <$> openGitRepository directory
else
if parent == directory
then pure Nothing
else go parent
-- | Return the canonical absolute root of the working tree.
repositoryRoot :: GitRepository -> FilePath
repositoryRoot = gitRepositoryRoot
-- | Check a repository-relative path against Git ignore rules. The supplied
-- kind is authoritative; the candidate itself is never inspected.
isIgnored :: GitRepository -> PathKind -> FilePath -> IO Bool
isIgnored repository kind rawPath =
case validateRepositoryPath rawPath of
Left err -> throwIO err
Right relative
| relative == "." -> pure False
| isRepositoryMetadata repository relative -> pure True
| otherwise -> do
initialGroups <- rootRuleContext repository
evaluateAncestors initialGroups (ancestorDirectories relative)
where
evaluateAncestors groups [] =
pure $ evaluatePatternGroups kind groups relative
evaluateAncestors groups (directory : directories)
| evaluatePatternGroups Directory groups directory = pure True
| otherwise = do
directoryGroup <- loadDirectoryRules repository directory
evaluateAncestors (groups <> [directoryGroup]) directories
-- | Rules applicable to entries directly under the repository root. Base
-- rules precede the lazily loaded root @.gitignore@ group.
rootRuleContext :: GitRepository -> IO [PatternGroup]
rootRuleContext repository = do
rootRules <- loadDirectoryRules repository "."
pure
[ PatternGroup
{ patternGroupPrefix = "/"
, patternGroupPatterns = gitRepositoryBasePatterns repository
}
, rootRules
]
-- | Load and cache the @.gitignore@ belonging to a repository-relative
-- directory. Symlinked ignore files are deliberately not followed.
loadDirectoryRules :: GitRepository -> FilePath -> IO PatternGroup
loadDirectoryRules repository =
loadDirectoryRulesWith repository $ \directory ->
loadPatternsFile False $
repositoryRoot repository
</> fromPosix directory
</> ".gitignore"
-- | Variant with an injectable loader for concurrency tests.
loadDirectoryRulesWith ::
GitRepository ->
(FilePath -> IO [Pattern]) ->
FilePath ->
IO PatternGroup
loadDirectoryRulesWith repository loadPatterns rawDirectory = do
directory <-
case validateRepositoryPath rawDirectory of
Left err -> throwIO err
Right validDirectory -> pure validDirectory
mask $ \restore -> do
(promise, ownsLoad) <-
modifyMVar (gitRepositoryRuleCache repository) $ \cache ->
case Map.lookup directory cache of
Just existing -> pure (cache, (existing, False))
Nothing -> do
created <- newEmptyMVar
pure (Map.insert directory created cache, (created, True))
if ownsLoad
then do
result <- try . restore $ loadRules directory
if either isAsyncException (const False) result
then do
modifyMVar_
(gitRepositoryRuleCache repository)
(pure . Map.delete directory)
putMVar promise RuleLoadCancelled
either throwIO pure result
else do
putMVar promise $ RuleLoadFinished result
either throwIO pure result
else do
cached <- restore $ readMVar promise
case cached of
RuleLoadFinished result -> either throwIO pure result
RuleLoadCancelled ->
loadDirectoryRulesWith repository loadPatterns directory
where
loadRules directory = do
patterns <- loadPatterns directory
pure
PatternGroup
{ patternGroupPrefix = groupPrefix directory
, patternGroupPatterns = patterns
}
isAsyncException exception =
isJust (fromException exception :: Maybe AsyncException)
-- | Whether a repository-relative path names the working tree's own Git
-- metadata. Nested @.git@ names belong to nested working trees and are not
-- classified as this repository's metadata.
isRepositoryMetadata :: GitRepository -> FilePath -> Bool
isRepositoryMetadata repository path =
any (`containsPath` candidate) metadataPaths
where
candidate = Posix.normalise $ toPosix path
metadataPaths =
".git"
: foldr
addInternalMetadata
[]
[ gitRepositoryGitDirectory repository
, gitRepositoryCommonDirectory repository
]
addInternalMetadata absolutePath paths =
let relative = toPosix $ makeRelative (repositoryRoot repository) absolutePath
in if isOutside relative || relative == "."
then paths
else Posix.normalise relative : paths
containsPath metadata candidatePath =
candidatePath == metadata
|| (metadata <> "/") `List.isPrefixOf` candidatePath
-- | Validate and normalize a repository-relative lexical path.
validateRepositoryPath :: FilePath -> Either GitError FilePath
validateRepositoryPath rawPath
| invalid = Left $ InvalidRepositoryPath rawPath
| otherwise = Right normalized
where
posixPath = toPosix rawPath
components = Posix.splitDirectories posixPath
invalid =
isAbsolute rawPath
|| Posix.isAbsolute posixPath
|| (pathSeparator == '\\' && hasWindowsDrive posixPath)
|| '\NUL' `elem` rawPath
|| ".." `elem` components
normalized =
case Posix.normalise posixPath of
"" -> "."
value -> value
-- | Convert a repository-relative directory to a normalized pattern-group
-- prefix.
groupPrefix :: FilePath -> FilePath
groupPrefix directory
| normalized `elem` ["", ".", "/"] = "/"
| otherwise = "/" <> stripSlashes normalized <> "/"
where
normalized = Posix.normalise $ toPosix directory
resolveGitDirectory :: FilePath -> IO FilePath
resolveGitDirectory root = do
let metadata = root </> ".git"
isDirectory <- doesDirectoryExist metadata
if isDirectory
then canonicalizePath metadata
else do
isFile <- doesFileExist metadata
if isFile
then resolveGitFile root metadata
else throwIO $ NotGitRepository root
resolveGitFile :: FilePath -> FilePath -> IO FilePath
resolveGitFile root metadata = do
firstLine <- readFirstLine metadata
case Text.stripPrefix "gitdir:" firstLine of
Nothing ->
throwIO $
InvalidGitMetadata metadata "expected a 'gitdir:' declaration"
Just rawGitDirectory -> do
let declared = Text.unpack $ Text.strip rawGitDirectory
if null declared
then throwIO $ InvalidGitMetadata metadata "empty gitdir path"
else do
let resolved =
if isAbsolute declared
then declared
else root </> declared
exists <- doesDirectoryExist resolved
unless exists $
throwIO (InvalidGitMetadata metadata "gitdir does not exist")
canonicalizePath resolved
resolveCommonDirectory :: FilePath -> IO FilePath
resolveCommonDirectory gitDirectory = do
let commonFile = gitDirectory </> "commondir"
exists <- doesFileExist commonFile
if not exists
then pure gitDirectory
else do
rawCommonDirectory <- Text.unpack . Text.strip <$> readFirstLine commonFile
if null rawCommonDirectory
then throwIO $ InvalidGitMetadata commonFile "empty commondir path"
else do
let resolved =
if isAbsolute rawCommonDirectory
then rawCommonDirectory
else gitDirectory </> rawCommonDirectory
isDirectory <- doesDirectoryExist resolved
unless isDirectory $
throwIO (InvalidGitMetadata commonFile "commondir does not exist")
canonicalizePath resolved
readFirstLine :: FilePath -> IO Text.Text
readFirstLine path = do
content <- Text.readFile path
pure . fromMaybe Text.empty . safeHead $ Text.lines content
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (value : _) = Just value
gitMetadataExists :: FilePath -> IO Bool
gitMetadataExists root = do
let metadata = root </> ".git"
isDirectory <- doesDirectoryExist metadata
isFile <- doesFileExist metadata
pure $ isDirectory || isFile
ancestorDirectories :: FilePath -> [FilePath]
ancestorDirectories relative =
case directoryComponents of
[] -> []
first : rest -> scanl (Posix.</>) first rest
where
directory = Posix.takeDirectory relative
directoryComponents =
filter (`notElem` ["", ".", "/"]) $
Posix.splitDirectories directory
hasWindowsDrive :: FilePath -> Bool
hasWindowsDrive (letter : ':' : _) = Char.isAlpha letter
hasWindowsDrive _ = False
isOutside :: FilePath -> Bool
isOutside path =
Posix.isAbsolute path
|| case Posix.splitDirectories path of
".." : _ -> True
_ -> False
toPosix :: FilePath -> FilePath
toPosix = fmap replaceSeparator
where
replaceSeparator character
| character == pathSeparator = '/'
| otherwise = character
fromPosix :: FilePath -> FilePath
fromPosix "." = ""
fromPosix path = fmap replaceSeparator path
where
replaceSeparator '/' = pathSeparator
replaceSeparator character = character
stripSlashes :: FilePath -> FilePath
stripSlashes = List.dropWhileEnd (== '/') . dropWhile (== '/')