diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog for vcs-ignore
+
+## v0.0.1.0 (2021-05-10)
+- initial release
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2020-2021, Vaclav Svejcar
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,65 @@
+
+# vcs-ignore
+`vcs-ignore` is small Haskell library used to find, check and process files ignored by selected _VCS_.
+
+## 1. Table of Contents
+
+- [1. Table of Contents](#1-table-of-contents)
+- [2. Example of Use](#2-example-of-use)
+    - [2.1. Listing all files/directories ignored by VCS](#21-listing-all-filesdirectories-ignored-by-vcs)
+    - [2.2. Walking files/directories ignored by VCS](#22-walking-filesdirectories-ignored-by-vcs)
+    - [2.3. Checking if path is ignored by VCS](#23-checking-if-path-is-ignored-by-vcs)
+
+
+
+## 2. Example of Use
+Because this library is really simple to use, following example should be enough to understand how to use it for your project.
+
+### 2.1. Listing all files/directories ignored by VCS
+```haskell
+{-# LANGUAGE TypeApplications #-}
+
+module Data.VCS.Test where
+
+import Data.VCS.Ignore ( Git, Repo(..), listRepo )
+
+example :: IO [FilePath]
+example = do
+  repo <- scanRepo @Git "path/to/repo"
+  listRepo repo
+```
+
+### 2.2. Walking files/directories ignored by VCS
+```haskell
+{-# LANGUAGE TypeApplications #-}
+
+module Data.VCS.Test where
+
+import Data.Maybe       ( catMaybes )
+import System.Directory ( doesFileExist )
+import Data.VCS.Ignore  ( Git, Repo(..), walkRepo )
+
+onlyFiles :: IO [FilePath]
+onlyFiles = do
+  repo <- scanRepo @Git "path/to/repo"
+  catMaybes <$> walkRepo repo walkFn
+ where
+  walkFn path = do
+    file <- doesFileExist path
+    pure (if file then Just path else Nothing)
+
+```
+
+### 2.3. Checking if path is ignored by VCS
+```haskell
+{-# LANGUAGE TypeApplications #-}
+
+module Data.VCS.Test where
+
+import Data.VCS.Ignore ( Git, Repo(..) )
+
+checkIgnored :: IO Bool
+checkIgnored = do
+  repo <- scanRepo @Git "path/to/repo"
+  isIgnored repo "/some/path/.DS_Store"
+```
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,78 @@
+{-# LANGUAGE RecordWildCards  #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns     #-}
+
+{-|
+Module      : Main
+Description : Simple application using the /vcs-ignore/ library
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+This simple application demonstrates the use of "vcs-ignore" library. It allows
+to check whether path given as argument is ignored within existing /GIT/ repo.
+-}
+
+module Main
+  ( main
+  )
+where
+
+import           Control.Monad                  ( when )
+import           Data.VCS.Ignore.Core           ( findRepo )
+import           Data.VCS.Ignore.Repo           ( Repo(..) )
+import           Data.VCS.Ignore.Repo.Git       ( Git )
+import           Main.Options                   ( Mode(..)
+                                                , Options(..)
+                                                , optionsParser
+                                                )
+import           Options.Applicative            ( execParser )
+import           System.Directory               ( canonicalizePath
+                                                , getCurrentDirectory
+                                                )
+import           System.Exit                    ( exitFailure
+                                                , exitSuccess
+                                                )
+import           System.FilePath                ( makeRelative )
+
+
+main :: IO ()
+main = do
+  options <- execParser optionsParser
+  repo    <- findRepoOrFail @Git options
+  executeMode repo options
+
+
+findRepoOrFail :: (Repo r, Show r) => Options -> IO r
+findRepoOrFail Options {..} = do
+  repoDir   <- getCurrentDirectory
+  maybeRepo <- findRepo repoDir
+  case maybeRepo of
+    Just repo -> do
+      putStrLn $ "Found repository at: " <> repoRoot repo
+      when oDebug (putStrLn $ "Repository details: " <> show repo)
+      pure repo
+    Nothing -> do
+      putStrLn $ "No repository found for path: " <> repoDir
+      exitFailure
+
+
+executeMode :: Repo r => r -> Options -> IO ()
+executeMode repo (oMode -> Path path) = checkPath repo path
+
+
+checkPath :: Repo r => r -> FilePath -> IO ()
+checkPath repo path = do
+  relative <- makeRelative (repoRoot repo) <$> canonicalizePath path
+  excluded <- isIgnored repo relative
+  if excluded then reportIgnored else reportNotIgnored
+ where
+  reportIgnored = do
+    putStrLn $ "Path '" <> path <> "' IS ignored"
+    exitSuccess
+  reportNotIgnored = do
+    putStrLn $ "Path '" <> path <> "' IS NOT ignored"
+    exitFailure
+
diff --git a/app/Main/Options.hs b/app/Main/Options.hs
new file mode 100644
--- /dev/null
+++ b/app/Main/Options.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE StrictData #-}
+
+{-|
+Module      : Main.Options
+Description : Options definitions for "optparse-applicative"
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+Options definitions for "optparse-applicative".
+-}
+
+module Main.Options
+  ( Options(..)
+  , Mode(..)
+  , optionsParser
+  )
+where
+
+import           Main.Vendor                    ( productDesc
+                                                , productInfo
+                                                )
+import           Options.Applicative
+
+
+data Options = Options
+  { oMode  :: Mode
+  , oDebug :: Bool
+  }
+  deriving (Eq, Show)
+
+data Mode = Path FilePath
+  deriving (Eq, Show)
+
+
+optionsParser :: ParserInfo Options
+optionsParser = info (options <**> helper)
+                     (fullDesc <> progDesc productDesc <> header productInfo)
+ where
+  options =
+    Options
+      <$> (   Path
+          <$> strOption
+                (long "path" <> short 'p' <> metavar "PATH" <> help
+                  "path to check"
+                )
+          )
+      <*> switch (long "debug" <> help "produce more verbose output")
diff --git a/app/Main/Vendor.hs b/app/Main/Vendor.hs
new file mode 100644
--- /dev/null
+++ b/app/Main/Vendor.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{-|
+Module      : Main.Vendor
+Description : Details about the application.
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+Module providing info about this application.
+-}
+
+module Main.Vendor
+  ( buildVersion
+  , productDesc
+  , productInfo
+  , productName
+  , webRepo
+  )
+where
+
+import           Data.String                    ( IsString(..) )
+import           Data.Version                   ( showVersion )
+import           Paths_vcs_ignore               ( version )
+
+
+-- | Product version.
+buildVersion :: IsString a => a
+buildVersion = fromString . showVersion $ version
+
+
+productDesc :: IsString a => a
+productDesc = "library for handling files ignored by VCS systems"
+
+
+-- | Product info.
+productInfo :: (IsString a, Monoid a) => a
+productInfo = mconcat [productName, ", v", buildVersion, " :: ", webRepo]
+
+
+-- | Product full name.
+productName :: IsString a => a
+productName = "vcs-ignore"
+
+
+-- | Product source code repository.
+webRepo :: IsString a => a
+webRepo = "https://github.com/vaclavsvejcar/vcs-ignore"
diff --git a/doctest/Main.hs b/doctest/Main.hs
new file mode 100644
--- /dev/null
+++ b/doctest/Main.hs
@@ -0,0 +1,6 @@
+module Main (main) where
+
+import Test.DocTest
+    
+main :: IO ()
+main = doctest ["-XOverloadedStrings", "src"]
diff --git a/src/Data/VCS/Ignore.hs b/src/Data/VCS/Ignore.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/VCS/Ignore.hs
@@ -0,0 +1,89 @@
+{-|
+Module      : Data.VCS.Ignore
+Description : Reexported modules for convenience
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+@vcs-ignore@ is small Haskell library used to find, check and process files
+ignored by selected /VCS/.
+
+= Example of Use
+Because this library is really simple to use, following example should be
+enough to understand how to use it for your project.
+
+== Listing all files/directories ignored by VCS
+@
+{-# LANGUAGE TypeApplications #-}
+
+module Data.VCS.Test where
+
+import Data.VCS.Ignore ( Git, Repo(..), listRepo )
+
+example :: IO [FilePath]
+example = do
+  repo <- scanRepo @Git "path/to/repo"
+  listRepo repo
+@
+
+== Walking files/directories ignored by VCS
+@
+{-# LANGUAGE TypeApplications #-}
+
+module Data.VCS.Test where
+
+import Data.Maybe       ( catMaybes )
+import System.Directory ( doesFileExist )
+import Data.VCS.Ignore  ( Git, Repo(..), walkRepo )
+
+onlyFiles :: IO [FilePath]
+onlyFiles = do
+  repo <- scanRepo @Git "path/to/repo"
+  catMaybes <$> walkRepo repo walkFn
+ where
+  walkFn path = do
+    file <- doesFileExist path
+    pure (if file then Just path else Nothing)
+@
+
+== Checking if path is ignored by VCS
+@
+{-# LANGUAGE TypeApplications #-}
+
+module Data.VCS.Test where
+
+import Data.VCS.Ignore ( Git, Repo(..) )
+
+checkIgnored :: IO Bool
+checkIgnored = do
+  repo <- scanRepo @Git "path/to/repo"
+  isIgnored repo "/some/path/.DS_Store"
+@
+-}
+
+module Data.VCS.Ignore
+  ( -- Working with ignored files
+    findRepo
+  , listRepo
+  , walkRepo
+    -- * Repo /type class/
+  , Repo(..)
+  , RepoError(..)
+    -- * /GIT/ implementation
+  , Git(..)
+    -- * Common data types
+  , VCSIgnoreError(..)
+  )
+where
+
+import           Data.VCS.Ignore.Core           ( findRepo
+                                                , listRepo
+                                                , walkRepo
+                                                )
+import           Data.VCS.Ignore.Repo           ( Repo(..)
+                                                , RepoError(..)
+                                                )
+import           Data.VCS.Ignore.Repo.Git       ( Git(..) )
+import           Data.VCS.Ignore.Types          ( VCSIgnoreError(..) )
diff --git a/src/Data/VCS/Ignore/Core.hs b/src/Data/VCS/Ignore/Core.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/VCS/Ignore/Core.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE StrictData       #-}
+{-# LANGUAGE TypeApplications #-}
+
+{-|
+Module      : Data.VCS.Ignore.Core
+Description : Core operations over the repository
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+This module contains core operations you can perform over the scanned 'Repo'.
+-}
+
+module Data.VCS.Ignore.Core
+  ( findRepo
+  , listRepo
+  , walkRepo
+  )
+where
+
+import           Control.Exception              ( try )
+import           Control.Monad.IO.Class         ( MonadIO
+                                                , liftIO
+                                                )
+import qualified Data.List                     as L
+import           Data.Maybe                     ( catMaybes
+                                                , fromMaybe
+                                                )
+import           Data.VCS.Ignore.FileSystem     ( walkPaths )
+import           Data.VCS.Ignore.Repo           ( Repo(..) )
+import           Data.VCS.Ignore.Types          ( VCSIgnoreError )
+import           System.FilePath                ( pathSeparator
+                                                , takeDirectory
+                                                )
+
+
+-- | Attempts to find (and scan via 'scanRepo') repository at given path.
+-- If given path doesn't contain valid repository, it recursively tries in every
+-- parent directory until the root directory (e.g. @C:@ or @/@) is reached.
+findRepo :: (MonadIO m, Repo r)
+         => FilePath
+         -- ^ path where to start scanning
+         -> m (Maybe r)
+         -- ^ scanned 'Repo' (if found)
+findRepo = liftIO . go
+ where
+  go dir = do
+    let parent = takeDirectory dir
+    maybeRepo <- try @VCSIgnoreError (scanRepo dir)
+    case maybeRepo of
+      Left _ | parent == dir -> pure Nothing
+      Left  _                -> go parent
+      Right repo             -> pure . Just $ repo
+
+
+-- | Resursively lists all non-ignored paths withing the given repository
+-- (both files and directories).
+listRepo :: (MonadIO m, Repo r)
+         => r
+         -- ^ repository to list
+         -> m [FilePath]
+         -- ^ list of non-ignored paths within the repository
+listRepo repo = walkRepo repo pure
+
+
+-- | Similar to 'listRepo', but allows to perform any action on every
+-- non-ignored path within the repository.
+walkRepo :: (MonadIO m, Repo r)
+         => r
+         -- ^ repository to walk
+         -> (FilePath -> m a)
+         -- ^ action to perform on every non-excluded filepath
+         -> m [a]
+         -- ^ list of paths transformed by the action function
+walkRepo repo fn = do
+  let search path | L.null path = pure Nothing
+                  | otherwise   = doSearch path
+  catMaybes <$> walkPaths root' (search . relativePath)
+ where
+  ps           = [pathSeparator]
+  root         = repoRoot repo
+  root'        = if ps `L.isSuffixOf` root then root else root <> ps
+  relativePath = dropPrefix root'
+  dropPrefix   = \prefix t -> fromMaybe t (L.stripPrefix prefix t)
+  doSearch     = \path -> isIgnored repo path >>= process path
+  process      = \path x -> if x then pure Nothing else Just <$> fn path
diff --git a/src/Data/VCS/Ignore/FileSystem.hs b/src/Data/VCS/Ignore/FileSystem.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/VCS/Ignore/FileSystem.hs
@@ -0,0 +1,109 @@
+{-# LANGUAGE MultiWayIf #-}
+
+{-|
+Module      : Data.VCS.Ignore.FileSystem
+Description : Helper functions for working with file system
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+This module contains mainly helper functions, that are internally used by this
+library.
+-}
+
+module Data.VCS.Ignore.FileSystem
+  ( findPaths
+  , listPaths
+  , walkPaths
+  , toPosixPath
+  )
+where
+
+
+import           Control.Monad                  ( forM
+                                                , mfilter
+                                                )
+import           Control.Monad.IO.Class         ( MonadIO
+                                                , liftIO
+                                                )
+import           Data.Maybe                     ( catMaybes
+                                                , fromMaybe
+                                                )
+import           System.Directory               ( doesDirectoryExist
+                                                , doesFileExist
+                                                , getDirectoryContents
+                                                )
+import           System.FilePath                ( (</>) )
+
+
+-- | Recursively finds paths on given path whose filename matches the predicate.
+findPaths :: MonadIO m
+          => FilePath
+          -- ^ path to traverse
+          -> (FilePath -> m Bool)
+          -- ^ predicate to match filename (performing possible I/O actions)
+          -> m [FilePath]
+          -- ^ list of found paths
+findPaths entryPath predicate = catMaybes <$> walkPaths entryPath process
+ where
+  process path = (\p -> if p then Just path else Nothing) <$> predicate path
+
+
+-- | Recursively finds all paths on given path. If file reference is passed
+-- instead of directory, such path is returned.
+listPaths :: MonadIO m
+          => FilePath
+          -- ^ path to traverse
+          -> m [FilePath]
+          -- ^ list of found paths
+listPaths entryPath = walkPaths entryPath pure
+
+
+-- | Recursively walks the given path and performs selected action for each
+-- found file. Output of this function is:
+--
+--   * If the given __path is file__, only this single path is processed and
+--     returned.
+--   * If the given __path is directory__, all subdirectories and files are
+--     recursively processed and returned.
+--   * If the given __path doesn't exist__, empy list will be returned.
+walkPaths :: MonadIO m
+          => FilePath
+          -- ^ path to traverse
+          -> (FilePath -> m a)
+          -- ^ function to process path (performing possible I/O actions)
+          -> m [a]
+          -- ^ result of traversed & processed paths
+walkPaths entryPath fn = do
+  isDir  <- liftIO $ doesDirectoryExist entryPath
+  isFile <- liftIO $ doesFileExist entryPath
+  if
+    | isDir     -> fn entryPath >>= (\p -> (p :) <$> listDirectory entryPath)
+    | isFile    -> pure <$> fn entryPath
+    | otherwise -> pure []
+ where
+  listDirectory dir = do
+    names <- liftIO $ getDirectoryContents dir
+    paths <- forM (filter (`notElem` [".", ".."]) names) $ \name -> do
+      let path = dir </> name
+      isDirectory <- liftIO $ doesDirectoryExist path
+      if isDirectory then walkPaths path fn else pure <$> fn path
+    pure $ concat paths
+
+
+-- | If the given path contains backward slashes (Windows style), converts them
+-- into forward ones (Unix style).
+--
+-- >>> toPosixPath "foo\\bar\\x.txt"
+-- "foo/bar/x.txt"
+--
+-- >>> toPosixPath "foo/bar/x.txt"
+-- "foo/bar/x.txt"
+toPosixPath :: FilePath
+            -- ^ input filepath to convert
+            -> FilePath
+            -- ^ output filepath
+toPosixPath = replace '\\' '/'
+  where replace a b = fmap $ fromMaybe b . mfilter (/= a) . Just
diff --git a/src/Data/VCS/Ignore/Repo.hs b/src/Data/VCS/Ignore/Repo.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/VCS/Ignore/Repo.hs
@@ -0,0 +1,84 @@
+{-# LANGUAGE StrictData #-}
+
+{-|
+Module      : Data.VCS.Ignore.Repo
+Description : Type class representing the VCS repository
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+This module contains /type class/ representing the selected type of /VCS/
+repository.
+-}
+
+module Data.VCS.Ignore.Repo
+  ( Repo(..)
+  , RepoError(..)
+  )
+where
+
+import           Control.Exception              ( Exception(..) )
+import           Control.Monad.Catch            ( MonadThrow )
+import           Control.Monad.IO.Class         ( MonadIO )
+import           Data.Text                      ( Text )
+import qualified Data.Text                     as T
+import           Data.VCS.Ignore.Types          ( fromVCSIgnoreError
+                                                , toVCSIgnoreError
+                                                )
+
+
+-- | /Type class/ representing instance of /VCS/ repository of selected type.
+-- In order to obtain instance, the physical repository needs to be scanned
+-- first by the 'scanRepo' method. Then absolute path to the repository root is
+-- provided by 'repoRoot' method. To check if any path (relative to the repo
+-- root) is ignored or not, use the 'isIgnored' method.
+class Repo r where
+
+  -- | Returns name of the repository (e.g. @GIT@).
+  repoName :: r
+           -- ^ /VCS/ repository instance
+           -> Text
+           -- ^ name of the repository
+
+  -- | Returns absolute path to the root of the /VCS/ repository.
+  repoRoot :: r
+           -- ^ /VCS/ repository instance
+           -> FilePath
+           -- ^ absolute path to the repository
+
+  -- | Scans repository at given path. If the given path doesn't contain valid
+  -- repository, 'RepoError' may be thrown.
+  scanRepo :: (MonadIO m, MonadThrow m)
+           => FilePath
+           -- ^ path to the /VCS/ repository root
+           -> m r
+           -- ^ scanned repository (or failure)
+
+  -- | Checks whether the given path is ignored. The input path is expected to
+  -- be relative to the repository root, it might or might not point to existing
+  -- file or directory.
+  isIgnored :: MonadIO m
+            => r
+            -- ^ /VCS/ repository instance
+            -> FilePath
+            -- ^ path to check, relative to the repository root
+            -> m Bool
+            -- ^ whether the path is ignored or not
+
+
+-- | Represents error related to operations over the /VCS/ repository.
+data RepoError = InvalidRepo FilePath Text
+  -- ^ Given 'FilePath' doesn't contain valid /VCS/ repository root.
+  deriving (Eq, Show)
+
+instance Exception RepoError where
+  displayException = displayException'
+  fromException    = fromVCSIgnoreError
+  toException      = toVCSIgnoreError
+
+
+displayException' :: RepoError -> String
+displayException' (InvalidRepo path reason) =
+  mconcat ["Path '", path, "' is not a valid repository: ", T.unpack reason]
diff --git a/src/Data/VCS/Ignore/Repo/Git.hs b/src/Data/VCS/Ignore/Repo/Git.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/VCS/Ignore/Repo/Git.hs
@@ -0,0 +1,297 @@
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE StrictData          #-}
+{-# LANGUAGE TupleSections       #-}
+
+{-|
+Module      : Data.VCS.Ignore.Repo.Git
+Description : Implementation of 'Repo' for /GIT/
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+This module contains implementation of 'Repo' /type class/ for the /GIT/ content
+versioning system. Most of the public functions is exported only to make them
+visible for tests, end user of this library really shouldn't need to use them.
+-}
+
+module Data.VCS.Ignore.Repo.Git
+  ( Git(..)
+  , Pattern(..)
+  , compilePattern
+  , matchesPattern
+  , parsePatterns
+  , loadPatterns
+  , findGitIgnores
+  , gitIgnorePatterns
+  , repoPatterns
+  , globalPatterns
+  , scanRepo'
+  , isIgnored'
+  , isGitRepo
+  )
+where
+
+import           Control.Exception              ( SomeException
+                                                , catch
+                                                )
+import           Control.Monad.Catch            ( MonadThrow
+                                                , throwM
+                                                )
+import           Control.Monad.IO.Class         ( MonadIO
+                                                , liftIO
+                                                )
+import qualified Data.List                     as L
+import           Data.Maybe                     ( fromMaybe
+                                                , maybeToList
+                                                )
+import           Data.String                    ( IsString(..) )
+import           Data.Text                      ( Text )
+import qualified Data.Text                     as T
+import qualified Data.Text.IO                  as T
+import           Data.VCS.Ignore.FileSystem     ( findPaths
+                                                , toPosixPath
+                                                )
+import           Data.VCS.Ignore.Repo           ( Repo(..)
+                                                , Repo(..)
+                                                , RepoError(..)
+                                                )
+import           System.Directory               ( XdgDirectory(XdgConfig)
+                                                , canonicalizePath
+                                                , doesDirectoryExist
+                                                , getXdgDirectory
+                                                , makeAbsolute
+                                                )
+import           System.FilePath                ( makeRelative
+                                                , (</>)
+                                                )
+import qualified System.FilePath.Glob          as G
+
+
+-- | Data type representing scanned instance of /GIT/ repository.
+data Git = Git
+  { gitRepoRoot :: FilePath
+  -- ^ absolute path to the repository root
+  , gitPatterns :: [(FilePath, [Pattern])]
+  -- ^ patterns ignored at given repository paths
+  }
+  deriving (Eq, Show)
+
+instance Repo Git where
+  repoName  = const "Git"
+  repoRoot  = gitRepoRoot
+  scanRepo  = scanRepo' globalPatterns repoPatterns gitIgnorePatterns isGitRepo
+  isIgnored = isIgnored'
+
+-- | Represents single pattern to be used as a rule for ignoring paths.
+data Pattern = Pattern
+  { pPatterns  :: [G.Pattern]
+  -- ^ underlying implementation
+  , pRaw       :: Text
+  -- ^ raw textual representation of the pattern
+  , pIsNegated :: Bool
+  -- ^ whether the pattern is the negation (starts with @!@)
+  }
+  deriving (Eq, Show)
+
+instance IsString Pattern where
+  fromString = compilePattern . T.pack
+
+
+------------------------------  PUBLIC FUNCTIONS  ------------------------------
+
+-- | Compiles pattern.
+compilePattern :: Text
+               -- ^ raw pattern as text
+               -> Pattern
+               -- ^ compiled pattern
+compilePattern raw =
+  let woPrefix = fromMaybe raw $ T.stripPrefix "!" raw
+      patterns = r2 . r1 $ woPrefix
+  in  Pattern { pPatterns  = fmap (G.compile . T.unpack) patterns
+              , pRaw       = raw
+              , pIsNegated = raw /= woPrefix
+              }
+ where
+  r1 p | any (`T.isPrefixOf` p) ["/", "*"] = p
+       | length (filter (not . T.null) . T.splitOn "/" $ p) == 1 = "**/" <> p
+       | otherwise                         = "/" <> p
+  r2 p | "/" `T.isSuffixOf` p = [p <> "**"]
+       | otherwise            = [p, p <> "/**"]
+
+
+-- | Tests whether given path matches against the pattern.
+matchesPattern :: Pattern
+               -- ^ pattern to match against
+               -> FilePath
+               -- ^ path to check
+               -> Bool
+               -- ^ check result
+matchesPattern ptn path = any (`G.match` path) (pPatterns ptn)
+
+
+-- | Parses /Glob/ patterns from given text source. Each line in input text is
+-- considered to be single pattern. Lines starting with @#@ (comments) and blank
+-- lines are skipped.
+--
+-- >>> parsePatterns "*.xml\n.DS_Store"
+-- [Pattern {pPatterns = [compile "*.xml",compile "*.xml/*"], pRaw = "*.xml", pIsNegated = False},Pattern {pPatterns = [compile "**/.DS_Store",compile "**/.DS_Store/*"], pRaw = ".DS_Store", pIsNegated = False}]
+parsePatterns :: Text
+              -- ^ text to parse
+              -> [Pattern]
+              -- ^ parsed patterns
+parsePatterns = fmap compilePattern . filter (not . excluded) . T.lines
+ where
+  excluded = \line -> or $ fmap ($ T.stripStart line) [comment, T.null]
+  comment  = \line -> "#" `T.isPrefixOf` line
+
+
+-- | Loads /Glob/ patterns from given text file. If the fille cannot be read for
+-- any reason, empty list is returned. See 'parsePatterns' for more details
+-- about parsing.
+loadPatterns :: MonadIO m
+             => FilePath
+             -- ^ path to text file to parse
+             -> m [Pattern]
+             -- ^ parsed /Glob/ patterns
+loadPatterns path = parsePatterns <$> liftIO content
+ where
+  content = catch (T.readFile path) (\(_ :: SomeException) -> pure T.empty)
+
+
+-- | Recursively finds all @.gitignore@ files within the given directory path.
+findGitIgnores :: MonadIO m
+               => FilePath
+               -- ^ path to the directory to search in
+               -> m [FilePath]
+               -- ^ paths of found @.gitignore@ files
+findGitIgnores repoDir = findPaths repoDir isGitIgnore
+  where isGitIgnore path = pure $ ".gitignore" `L.isSuffixOf` path
+
+
+-- | Recursively finds all @.gitignore@ files within the given directory path
+-- and parses them into /Glob/ patterns. See 'loadPatterns' and 'findGitIgnores'
+-- for more details.
+gitIgnorePatterns :: MonadIO m
+                  => FilePath
+                  -- ^ path to the directory to search @.gitignore@ files in
+                  -> m [(FilePath, [Pattern])]
+                  -- ^ list of @.gitignore@ paths and parsed /Glob/ patterns
+gitIgnorePatterns repoDir = do
+  gitIgnores <- findGitIgnores repoDir
+  mapM (\p -> (toPosixPath . path $ p, ) <$> loadPatterns p) gitIgnores
+  where path p = stripSuffix' ".gitignore" $ stripPrefix' repoDir p
+
+
+-- | Loads /GIT/ repository specific ignore patterns, present in
+-- @REPO_ROOT\/info\/exclude@ file.
+repoPatterns :: MonadIO m
+             => FilePath
+             -- ^ path to the /GIT/ repository root
+             -> m [Pattern]
+             -- ^ parsed /Glob/ patterns
+repoPatterns repoDir = loadPatterns $ repoDir </> "info" </> "exclude"
+
+
+-- | Loads global /GIT/  ignore patterns, present in
+-- @XDG_CONFIG_GOME\/git\/ignore@ file.
+globalPatterns :: MonadIO m => m [Pattern]
+               -- ^ parsed /Glob/ patterns
+globalPatterns =
+  (liftIO . getXdgDirectory XdgConfig $ ("git" </> "ignore")) >>= loadPatterns
+
+
+-- | Internal version of 'scanRepo', where individual functions needs to be
+-- explicitly provided, which is useful mainly for testing purposes.
+scanRepo' :: (MonadIO m, MonadThrow m)
+          => m [Pattern]
+          -- ^ reference to 'globalPatterns' function (or similar)
+          -> (FilePath -> m [Pattern])
+          -- ^ reference to 'repoPatterns' function (or similar)
+          -> (FilePath -> m [(FilePath, [Pattern])])
+          -- ^ reference to 'gitIgnorePatterns' function (or similar)
+          -> (FilePath -> m Bool)
+          -- ^ reference to 'isGitRepo' function (or similar)
+          -> FilePath
+          -- ^ path to /GIT/ repository root
+          -> m Git
+          -- ^ scanned /Git/ repository
+scanRepo' globalPatternsFn repoPatternsFn gitIgnoresFn isGitRepoFn repoDir = do
+  absRepoDir <- liftIO $ makeAbsolute repoDir
+  gitRepo    <- isGitRepoFn absRepoDir
+  (if gitRepo then proceed else abort) absRepoDir
+ where
+  abort repoDir' = throwM $ InvalidRepo repoDir' "not a valid GIT repository"
+  proceed repoDir' = do
+    globalPatterns' <- globalPatternsFn
+    repoPatterns'   <- repoPatternsFn repoDir'
+    gitIgnores      <- gitIgnoresFn repoDir'
+    let (r, o)   = sep gitIgnores
+        patterns = [("/", globalPatterns' <> repoPatterns' <> r)] <> o
+    pure Git { gitRepoRoot = repoDir', gitPatterns = patterns }
+  sep xs =
+    let predicate = \(p, _) -> p == "/"
+        woRoot    = filter (not . predicate) xs
+        root      = concat . maybeToList $ snd <$> L.find predicate xs
+    in  (root, woRoot)
+
+
+-- | Internal version of 'isIgnored' function.
+isIgnored' :: MonadIO m
+           => Git
+           -- ^ scanned /GIT/ repository
+           -> FilePath
+           -- ^ path to check if ignored
+           -> m Bool
+           -- @True@ if given path is ignored
+isIgnored' git@(Git _ patterns) path = do
+  np <- toPosixPath <$> normalize (repoRoot git) path
+  let ignored = any (check2 np False) (filtered np)
+      negated = any (check2 np True) (filtered np)
+  pure $ ignored && not negated
+ where
+  sanitized  = addPrefix "/"
+  asRepoPath = \np -> (`stripPrefix'` sanitized np)
+  filtered   = \np -> filter (onPath np) patterns
+  onPath     = \np (p, _) -> p `L.isPrefixOf` sanitized np
+  check2     = \np negated (prefix, ptns) ->
+    any (`matchesPattern` asRepoPath np prefix)
+      . filter (\p -> pIsNegated p == negated)
+      $ ptns
+
+
+-- | Checks whether given directory path is valid /GIT/ repository.
+isGitRepo :: MonadIO m
+          => FilePath
+          -- ^ path to the directory to check
+          -> m Bool
+          -- ^ @True@ if the given directory is valid /GIT/ repository
+isGitRepo path = liftIO . doesDirectoryExist $ path </> ".git"
+
+
+------------------------------  PRIVATE FUNCTIONS  -----------------------------
+
+addPrefix :: String -> String -> String
+addPrefix prefix str | prefix `L.isPrefixOf` str = str
+                     | otherwise                 = prefix <> str
+
+
+stripPrefix' :: String -> String -> String
+stripPrefix' prefix str =
+  maybe str T.unpack (T.stripPrefix (T.pack prefix) (T.pack str))
+
+
+stripSuffix' :: String -> String -> String
+stripSuffix' suffix str =
+  maybe str T.unpack (T.stripSuffix (T.pack suffix) (T.pack str))
+
+
+normalize :: MonadIO m => FilePath -> FilePath -> m FilePath
+normalize repoDir path = do
+  canonicalized <- liftIO . canonicalizePath $ repoDir </> stripPrefix' "/" path
+  isDir         <- liftIO $ doesDirectoryExist canonicalized
+  let suffix = if isDir || "/" `L.isSuffixOf` path then "/" else ""
+  pure $ makeRelative repoDir canonicalized <> suffix
+
diff --git a/src/Data/VCS/Ignore/Types.hs b/src/Data/VCS/Ignore/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/VCS/Ignore/Types.hs
@@ -0,0 +1,60 @@
+{-# LANGUAGE ExistentialQuantification #-}
+{-# LANGUAGE StrictData                #-}
+
+{-|
+Module      : Data.VCS.Ignore.Types
+Description : Shared data types
+Copyright   : (c) 2020-2021 Vaclav Svejcar
+License     : BSD-3-Clause
+Maintainer  : vaclav.svejcar@gmail.com
+Stability   : experimental
+Portability : POSIX
+
+This module contains data types and functions shared across the library.
+-}
+
+module Data.VCS.Ignore.Types
+  ( VCSIgnoreError(..)
+  , fromVCSIgnoreError
+  , toVCSIgnoreError
+  )
+where
+
+import           Control.Exception              ( Exception(..)
+                                                , SomeException
+                                                )
+import           Data.Typeable                  ( cast )
+
+
+---------------------------------  DATA TYPES  ---------------------------------
+
+-- | Top-level of any exception thrown by this library.
+data VCSIgnoreError = forall e . Exception e => VCSIgnoreError e
+
+instance Show VCSIgnoreError where
+  show (VCSIgnoreError e) = show e
+
+instance Exception VCSIgnoreError where
+  displayException (VCSIgnoreError e) = displayException e
+
+
+------------------------------  PUBLIC FUNCTIONS  ------------------------------
+
+-- | Unwraps given exception from 'VCSIgnoreError'.
+fromVCSIgnoreError :: Exception e
+                   => SomeException
+                   -- ^ exception to unwrap
+                   -> Maybe e
+                   -- ^ unwrapped exception
+fromVCSIgnoreError e = do
+  VCSIgnoreError e' <- fromException e
+  cast e'
+
+
+-- | Wraps given exception from 'VCSIgnoreError'.
+toVCSIgnoreError :: Exception e
+                 => e
+                 -- ^ exception to wrap
+                 -> SomeException
+                 -- ^ wrapped exception
+toVCSIgnoreError = toException . VCSIgnoreError
diff --git a/test-data/fake-git-repo/.gitignore b/test-data/fake-git-repo/.gitignore
new file mode 100644
--- /dev/null
+++ b/test-data/fake-git-repo/.gitignore
@@ -0,0 +1,1 @@
+foo
diff --git a/test-data/fake-git-repo/a/.gitignore b/test-data/fake-git-repo/a/.gitignore
new file mode 100644
--- /dev/null
+++ b/test-data/fake-git-repo/a/.gitignore
@@ -0,0 +1,3 @@
+# some comment
+
+**/*.xml
diff --git a/test-data/fake-git-repo/a/b/.gitignore b/test-data/fake-git-repo/a/b/.gitignore
new file mode 100644
--- /dev/null
+++ b/test-data/fake-git-repo/a/b/.gitignore
@@ -0,0 +1,1 @@
+*.txt
diff --git a/test-data/fake-git-repo/a/b/test-b.txt b/test-data/fake-git-repo/a/b/test-b.txt
new file mode 100644
--- /dev/null
+++ b/test-data/fake-git-repo/a/b/test-b.txt
diff --git a/test-data/fake-git-repo/a/b/test-b.xml b/test-data/fake-git-repo/a/b/test-b.xml
new file mode 100644
--- /dev/null
+++ b/test-data/fake-git-repo/a/b/test-b.xml
diff --git a/test-data/fake-git-repo/a/test-a.txt b/test-data/fake-git-repo/a/test-a.txt
new file mode 100644
--- /dev/null
+++ b/test-data/fake-git-repo/a/test-a.txt
diff --git a/test-data/fake-git-repo/a/test-a.xml b/test-data/fake-git-repo/a/test-a.xml
new file mode 100644
--- /dev/null
+++ b/test-data/fake-git-repo/a/test-a.xml
diff --git a/test-data/list-files/a.txt b/test-data/list-files/a.txt
new file mode 100644
--- /dev/null
+++ b/test-data/list-files/a.txt
diff --git a/test-data/list-files/dir1/b.txt b/test-data/list-files/dir1/b.txt
new file mode 100644
--- /dev/null
+++ b/test-data/list-files/dir1/b.txt
diff --git a/test-data/list-files/dir1/dir2/c.txt b/test-data/list-files/dir1/dir2/c.txt
new file mode 100644
--- /dev/null
+++ b/test-data/list-files/dir1/dir2/c.txt
diff --git a/test-data/list-files/dir1/dir2/d.xml b/test-data/list-files/dir1/dir2/d.xml
new file mode 100644
--- /dev/null
+++ b/test-data/list-files/dir1/dir2/d.xml
diff --git a/test/Data/VCS/Ignore/CoreSpec.hs b/test/Data/VCS/Ignore/CoreSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/VCS/Ignore/CoreSpec.hs
@@ -0,0 +1,87 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards   #-}
+{-# LANGUAGE StrictData        #-}
+{-# LANGUAGE TypeApplications  #-}
+
+module Data.VCS.Ignore.CoreSpec
+  ( spec
+  )
+where
+
+import           Control.Monad.Catch            ( throwM )
+import qualified Data.List                     as L
+import           Data.VCS.Ignore.Core
+
+import           Data.VCS.Ignore.Repo           ( Repo(..)
+                                                , RepoError(..)
+                                                )
+import           System.FilePath                ( (</>) )
+import           Test.Hspec
+
+
+spec :: Spec
+spec = do
+
+  describe "findRepo" $ do
+    it "finds repo for some path inside repo" $ do
+      let path     = testRepoRoot </> "a" </> "b"
+          expected = TestRepo testRepoRoot
+      findRepo path `shouldReturn` Just expected
+
+    it "finds no repo for path outside repo" $ do
+      let path = "some" </> "path"
+      findRepo path `shouldReturn` Nothing @TestRepo
+
+
+  describe "listRepo" $ do
+    it "lists repository paths, based on the search filter" $ do
+      let expected =
+            [ ".gitignore"
+            , "a"
+            , "a" </> ".gitignore"
+            , "a" </> "b"
+            , "a" </> "b" </> ".gitignore"
+            , "a" </> "b" </> "test-b.txt"
+            , "a" </> "b" </> "test-b.xml"
+            , "a" </> "test-a.txt"
+            , "a" </> "test-a.xml"
+            ]
+      repo   <- scanRepo @TestRepo testRepoRoot
+      result <- listRepo repo
+      L.sort result `shouldBe` L.sort expected
+
+
+  describe "walkRepo" $ do
+    it "walks repository paths, based on the search filter" $ do
+      let fn = \path -> pure ("foo" </> path)
+          expected =
+            [ "foo" </> ".gitignore"
+            , "foo" </> "a"
+            , "foo" </> "a" </> ".gitignore"
+            , "foo" </> "a" </> "b"
+            , "foo" </> "a" </> "b" </> ".gitignore"
+            , "foo" </> "a" </> "b" </> "test-b.txt"
+            , "foo" </> "a" </> "b" </> "test-b.xml"
+            , "foo" </> "a" </> "test-a.txt"
+            , "foo" </> "a" </> "test-a.xml"
+            ]
+      repo   <- scanRepo @TestRepo testRepoRoot
+      result <- walkRepo repo fn
+      L.sort result `shouldBe` L.sort expected
+
+
+data TestRepo = TestRepo
+  { trPath :: FilePath
+  }
+  deriving (Eq, Show)
+
+instance Repo TestRepo where
+  repoName = const "TestRepo"
+  repoRoot TestRepo {..} = trPath
+  scanRepo path | path == testRepoRoot = pure TestRepo { trPath = path }
+                | otherwise            = throwM $ InvalidRepo path "err"
+  isIgnored _ path = pure $ "excluded.txt" `L.isSuffixOf` path
+
+
+testRepoRoot :: FilePath
+testRepoRoot = "test-data" </> "fake-git-repo"
diff --git a/test/Data/VCS/Ignore/FileSystemSpec.hs b/test/Data/VCS/Ignore/FileSystemSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/VCS/Ignore/FileSystemSpec.hs
@@ -0,0 +1,65 @@
+module Data.VCS.Ignore.FileSystemSpec
+  ( spec
+  )
+where
+
+import qualified Data.List                     as L
+import           Data.Maybe                     ( catMaybes )
+import           Data.VCS.Ignore.FileSystem
+import           System.FilePath                ( (</>) )
+import           Test.Hspec
+
+
+spec :: Spec
+spec = do
+  describe "findPaths" $ do
+    it "recursively finds paths filtered by given predicate" $ do
+      let expected =
+            ["test-data" </> "list-files" </> "dir1" </> "dir2" </> "d.xml"]
+          predicate = pure <$> ("d.xml" `L.isSuffixOf`)
+      result <- findPaths ("test-data" </> "list-files") predicate
+      result `shouldBe` expected
+
+
+  describe "listPaths" $ do
+    it "returns empty list if path doesn't exist" $ do
+      filePaths <- listPaths "non-existing-path"
+      filePaths `shouldBe` []
+
+    it "recursively finds all paths in directory" $ do
+      result <- listPaths $ "test-data" </> "list-files"
+      let expected =
+            [ "test-data" </> "list-files"
+            , "test-data" </> "list-files" </> "a.txt"
+            , "test-data" </> "list-files" </> "dir1"
+            , "test-data" </> "list-files" </> "dir1" </> "b.txt"
+            , "test-data" </> "list-files" </> "dir1" </> "dir2"
+            , "test-data" </> "list-files" </> "dir1" </> "dir2" </> "c.txt"
+            , "test-data" </> "list-files" </> "dir1" </> "dir2" </> "d.xml"
+            ]
+      L.sort result `shouldBe` L.sort expected
+
+
+  describe "walkPaths" $ do
+    it "recursively traverses and processes paths in directory" $ do
+      let
+        fn path =
+          if ".txt" `L.isSuffixOf` path then pure $ Just path else pure Nothing
+        expected =
+          [ "test-data" </> "list-files" </> "a.txt"
+          , "test-data" </> "list-files" </> "dir1" </> "b.txt"
+          , "test-data" </> "list-files" </> "dir1" </> "dir2" </> "c.txt"
+          ]
+      actual <- catMaybes <$> walkPaths ("test-data" </> "list-files") fn
+      L.sort actual `shouldBe` L.sort expected
+
+
+  describe "toPosixPath" $ do
+    it "replaces any backward slashes with forward ones" $ do
+      let sample   = "foo\\bar\\x.txt"
+          expected = "foo/bar/x.txt"
+      toPosixPath sample `shouldBe` expected
+
+    it "keep forwars slashes as is" $ do
+      let sample = "foo/bar/x.txt"
+      toPosixPath sample `shouldBe` sample
diff --git a/test/Data/VCS/Ignore/Repo/GitSpec.hs b/test/Data/VCS/Ignore/Repo/GitSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/VCS/Ignore/Repo/GitSpec.hs
@@ -0,0 +1,139 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Data.VCS.Ignore.Repo.GitSpec where
+
+import qualified Data.List                     as L
+import qualified Data.Text                     as T
+import           Data.VCS.Ignore.Repo           ( RepoError(..) )
+import           Data.VCS.Ignore.Repo.Git
+import           System.Directory               ( makeAbsolute )
+import           System.FilePath                ( (</>) )
+import           Test.Hspec
+
+
+spec :: Spec
+spec = do
+  let repo = "test-data" </> "fake-git-repo"
+
+  describe "compilePattern" $ do
+    it "compiles negated pattern" $ do
+      compilePattern "!/foo/bar"
+        `shouldBe` Pattern ["/foo/bar", "/foo/bar/**"] "!/foo/bar" True
+
+    it "compiles pattern matching files or directories inside path" $ do
+      compilePattern ".hidden"
+        `shouldBe` Pattern ["**/.hidden", "**/.hidden/**"] ".hidden" False
+      compilePattern ".hidden/"
+        `shouldBe` Pattern ["**/.hidden/**"] ".hidden/" False
+
+
+  describe "matchesPattern" $ do
+    it "matches all content in subdirectory" $ do
+      matchesPattern (compilePattern "/foo/*") "/foo" `shouldBe` False
+      matchesPattern (compilePattern "/foo/*") "/foo/bar" `shouldBe` True
+
+    it "matches all content in all subdirectories" $ do
+      matchesPattern (compilePattern "/foo/**") "/foo/bar" `shouldBe` True
+      matchesPattern (compilePattern "/foo/**") "/foo/bar/c" `shouldBe` True
+
+    it "matches negated pattern" $ do
+      matchesPattern (compilePattern "!/foo/*") "/foo" `shouldBe` False
+      matchesPattern (compilePattern "!/foo/*") "/foo/bar" `shouldBe` True
+
+    it "matches dir pattern anywhere in the path" $ do
+      matchesPattern (compilePattern "bar/") "/foo/bar/x" `shouldBe` True
+      matchesPattern (compilePattern "bar/") "/foo/bar" `shouldBe` False
+
+    it "matches dir or file pattern anywhere in the path" $ do
+      matchesPattern (compilePattern "bar") "/foo/bar/x" `shouldBe` True
+      matchesPattern (compilePattern "bar") "/foo/bar" `shouldBe` True
+
+
+  describe "parsePatterns" $ do
+    it "parses glob patterns from input text (pattern per line)" $ do
+      let input    = T.unlines [".cabal-sandbox/", "## comment", ".DS_Store"]
+          expected = [".cabal-sandbox/", ".DS_Store"]
+      parsePatterns input `shouldBe` expected
+
+
+  describe "loadPatterns" $ do
+    it "loads and parses glob patterns from input file" $ do
+      let source   = repo </> "a" </> ".gitignore"
+          expected = ["**/*.xml"]
+      loadPatterns source `shouldReturn` expected
+
+    it "returns empty list if input cannot be read" $ do
+      let source = repo </> "non-existing"
+      loadPatterns source `shouldReturn` []
+
+
+  describe "findGitIgnores" $ do
+    it "finds all .gitignore files in repo" $ do
+      let expected =
+            [ repo </> "a" </> ".gitignore"
+            , repo </> "a" </> "b" </> ".gitignore"
+            , repo </> ".gitignore"
+            ]
+      L.sort <$> findGitIgnores repo `shouldReturn` L.sort expected
+
+
+  describe "gitIgnorePatterns" $ do
+    it "loads patterns for all .gitignore files in repo" $ do
+      let expected =
+            [("/a/", ["**/*.xml"]), ("/a/b/", ["*.txt"]), ("/", ["foo"])]
+      sortFst <$> gitIgnorePatterns repo `shouldReturn` sortFst expected
+
+
+  describe "scanRepo'" $ do
+    it "scans repository for ignored patterns" $ do
+      absRepo <- makeAbsolute repo
+      let
+        fn1      = pure []
+        fn2      = const $ pure []
+        fn3      = const $ pure True
+        expected = Git
+          { gitPatterns = sortFst
+            [("/", ["foo"]), ("/a/", ["**/*.xml"]), ("/a/b/", ["*.txt"])]
+          , gitRepoRoot = absRepo
+          }
+      result <- scanRepo' fn1 fn2 gitIgnorePatterns fn3 repo
+      let result' = result { gitPatterns = sortFst (gitPatterns result) }
+      result' `shouldBe` expected
+
+    it "aborts scanning if given path is not valid GIT repo" $ do
+      let fn1 = pure []
+          fn2 = const $ pure []
+          fn3 = const $ pure False
+      let err (InvalidRepo _ _) = True
+      scanRepo' fn1 fn2 gitIgnorePatterns fn3 repo `shouldThrow` err
+
+
+  describe "isIgnored'" $ do
+    it "checks whether given path is excluded" $ do
+      absRepo <- makeAbsolute repo
+      let git = Git
+            { gitPatterns = [ ("/"    , ["!keep.xml", ".hidden/", ".hid"])
+                            , ("/a/"  , ["**/*.xml"])
+                            , ("/a/b/", ["*.txt"])
+                            ]
+            , gitRepoRoot = absRepo
+            }
+      isIgnored' git "foo/bar" `shouldReturn` False
+      isIgnored' git "a/hello.txt" `shouldReturn` False
+      isIgnored' git "a/hello.xml" `shouldReturn` True
+      isIgnored' git "a/b/hello.xml" `shouldReturn` True
+      isIgnored' git "a/b/keep.xml" `shouldReturn` False
+      isIgnored' git "/foo/bar" `shouldReturn` False
+      isIgnored' git "/a/hello.txt" `shouldReturn` False
+      isIgnored' git "/a/hello.xml" `shouldReturn` True
+      isIgnored' git "/a/b/hello.xml" `shouldReturn` True
+      isIgnored' git "/a/b/../hello.txt" `shouldReturn` False
+      isIgnored' git "/a/b/../hello.xml" `shouldReturn` True
+      isIgnored' git "/a/b/.hidden" `shouldReturn` False
+      isIgnored' git "/a/b/.hidden/foo" `shouldReturn` True
+      isIgnored' git "/a/b/.hid" `shouldReturn` True
+      isIgnored' git "/a/b/.hid/foo" `shouldReturn` True
+
+
+sortFst :: Ord a => [(a, b)] -> [(a, b)]
+sortFst = L.sortOn fst
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,2 @@
+-- hspec test auto discovery - https://hspec.github.io/hspec-discover.html
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
diff --git a/vcs-ignore.cabal b/vcs-ignore.cabal
new file mode 100644
--- /dev/null
+++ b/vcs-ignore.cabal
@@ -0,0 +1,124 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.34.4.
+--
+-- see: https://github.com/sol/hpack
+
+name:           vcs-ignore
+version:        0.0.1.0
+synopsis:       Library for handling files ignored by VCS systems.
+description:    vcs-ignore is small Haskell library used to find, check and process files ignored by selected VCS.
+category:       Development
+homepage:       https://github.com/vaclavsvejcar/vcs-ignore
+bug-reports:    https://github.com/vaclavsvejcar/vcs-ignore/issues
+author:         Vaclav Svejcar
+maintainer:     vaclav.svejcar@gmail.com
+copyright:      Copyright (c) 2020-2021 Vaclav Svejcar
+license:        BSD-3-Clause
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    CHANGELOG.md
+    test-data/fake-git-repo/.gitignore
+    test-data/fake-git-repo/a/test-a.txt
+    test-data/fake-git-repo/a/test-a.xml
+    test-data/fake-git-repo/a/.gitignore
+    test-data/fake-git-repo/a/b/test-b.txt
+    test-data/fake-git-repo/a/b/test-b.xml
+    test-data/fake-git-repo/a/b/.gitignore
+    test-data/list-files/a.txt
+    test-data/list-files/dir1/b.txt
+    test-data/list-files/dir1/dir2/c.txt
+    test-data/list-files/dir1/dir2/d.xml
+
+source-repository head
+  type: git
+  location: https://github.com/vaclavsvejcar/vcs-ignore
+
+library
+  exposed-modules:
+      Data.VCS.Ignore
+      Data.VCS.Ignore.Core
+      Data.VCS.Ignore.FileSystem
+      Data.VCS.Ignore.Repo
+      Data.VCS.Ignore.Repo.Git
+      Data.VCS.Ignore.Types
+  other-modules:
+      Paths_vcs_ignore
+  autogen-modules:
+      Paths_vcs_ignore
+  hs-source-dirs:
+      src
+  ghc-options: -optP-Wno-nonportable-include-path -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -Werror=incomplete-patterns
+  build-depends:
+      Glob
+    , base >=4.7 && <5
+    , containers
+    , directory
+    , exceptions
+    , filepath
+    , text
+  default-language: Haskell2010
+
+executable ignore
+  main-is: Main.hs
+  other-modules:
+      Main.Options
+      Main.Vendor
+      Paths_vcs_ignore
+  hs-source-dirs:
+      app
+  ghc-options: -optP-Wno-nonportable-include-path -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -Werror=incomplete-patterns -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.7 && <5
+    , containers
+    , directory
+    , exceptions
+    , filepath
+    , optparse-applicative
+    , text
+    , vcs-ignore
+  default-language: Haskell2010
+
+test-suite doctest
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  other-modules:
+      Paths_vcs_ignore
+  hs-source-dirs:
+      doctest
+  ghc-options: -optP-Wno-nonportable-include-path -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -Werror=incomplete-patterns
+  build-depends:
+      base >=4.7 && <5
+    , containers
+    , directory
+    , doctest
+    , exceptions
+    , filepath
+    , text
+  default-language: Haskell2010
+
+test-suite spec
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Data.VCS.Ignore.CoreSpec
+      Data.VCS.Ignore.FileSystemSpec
+      Data.VCS.Ignore.Repo.GitSpec
+      Paths_vcs_ignore
+  hs-source-dirs:
+      test
+  ghc-options: -optP-Wno-nonportable-include-path -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -Werror=incomplete-patterns -threaded -rtsopts -with-rtsopts=-N
+  build-tool-depends:
+      hspec-discover:hspec-discover
+  build-depends:
+      base >=4.7 && <5
+    , containers
+    , directory
+    , exceptions
+    , filepath
+    , hspec
+    , text
+    , vcs-ignore
+  default-language: Haskell2010
