aboutsummaryrefslogtreecommitdiffstats
path: root/app/Git.hs
blob: 65ecf89fd501030d7abf12a51417a952f4ef2ba3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
module Git
  ( module Git.CommitHash,
    getCommitHashes,
    getRootDir,
    getChangedFilesOf,
    Commit (..),
    Author (..),
    getCommitOf,
    readTextFileOfText,
    readTextFileOfBS,
    resolveRef,
    getCommitsBetween,
    diffOf,
  )
where

import Control.Exception (IOException, catch, throw, throwIO)
import Data.Binary (Binary)
import Data.Binary.Instances ()
import Data.ByteString.Lazy qualified as LB
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as N
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Text.Lazy qualified as LT
import Data.Text.Lazy.Encoding qualified as LT
import Data.Text.Lazy.IO qualified as LT
import Data.Time.Clock (UTCTime, getCurrentTime)
import Exception qualified as E
import GHC.Generics (Generic)
import Git.CommitHash
import Patch qualified as A
import Process (proc, sh, sh_)
import Text.Printf (printf)

getCommitHashes :: IO (NonEmpty T.Text)
getCommitHashes = fromMaybe (throw E.NoCommits) . N.nonEmpty . reverse . T.lines <$> sh "git log --format=%H"

getRootDir :: IO FilePath
getRootDir =
  T.unpack . stripTrailingNL
    <$> sh (proc "git rev-parse --show-toplevel")
  where
    stripTrailingNL s = fromMaybe s $ T.stripSuffix "\n" s

getChangedFilesOf :: CommitHash -> IO [FilePath]
getChangedFilesOf WorkingTree =
  map T.unpack . T.lines
    <$> sh "git ls-files --modified"
getChangedFilesOf (Commit hash) =
  map T.unpack . T.lines
    <$> sh (proc "git show -p --name-only --format= %" hash)

data Commit = Commit'
  { commitHash :: CommitHash,
    date :: UTCTime,
    author :: Author
  }
  deriving (Show, Generic, Binary, Eq)

data Author = Author
  { name :: T.Text,
    email :: T.Text
  }
  deriving (Show, Generic, Binary, Eq)

getCommitOf :: CommitHash -> IO Commit
getCommitOf commitHash@WorkingTree = do
  date <- getCurrentTime
  authorName <- sh "git config user.name"
  authorEmail <- sh "git config user.email"
  pure
    Commit'
      { author = Author authorName authorEmail,
        ..
      }
getCommitOf commitHash@(Commit hash) = do
  ( T.splitOn "\NUL" . head . T.lines
      <$> sh
        ( proc
            "git show --quiet --format=%%ai%%x00%%ae%%x00%%an %"
            hash
        )
    )
    >>= \case
      rawDate : authorEmail : authorName : _ ->
        let date = read (T.unpack rawDate)
         in pure
              Commit'
                { author = Author authorName authorEmail,
                  ..
                }
      _ -> throwIO E.NoCommits

readTextFileOfText :: CommitHash -> FilePath -> IO LT.Text
readTextFileOfText = readTextFileOf LT.readFile LT.decodeUtf8

readTextFileOfBS :: CommitHash -> FilePath -> IO LB.ByteString
readTextFileOfBS = readTextFileOf LB.readFile id

readTextFileOf :: (FilePath -> IO a) -> (LB.ByteString -> a) -> CommitHash -> FilePath -> IO a
readTextFileOf readFile _ WorkingTree filePath =
  catch
    (readFile filePath)
    (\(_ :: IOException) -> throwIO (E.CannotReadFile filePath))
readTextFileOf _ decode (Commit hash) filePath =
  catch
    (decode <$> sh (proc "git show %:%" hash filePath))
    (\(_ :: E.ProcessException) -> throwIO (E.CannotReadFile (printf "%s:%s" hash filePath)))

resolveRef :: T.Text -> IO CommitHash
resolveRef =
  fmap (Commit . T.strip . T.decodeUtf8 . LB.toStrict)
    . sh
    . proc "git rev-parse %"

-- | `getCommitsBetween prevCommit commit` returns the commits from `prevCommit` to `commit`. The result excludes `prevCommit`, but includes `commit`.
--
-- If `prevCommit` is  not an ancestor of `commit`, this functions throws `NoAncestor commit prevCommit`.
getCommitsBetween :: CommitHash -> CommitHash -> IO [CommitHash]
getCommitsBetween WorkingTree commit@(Commit _) =
  throwIO (E.NoAncestor WorkingTree commit)
getCommitsBetween WorkingTree WorkingTree = pure [WorkingTree]
getCommitsBetween prevCommit WorkingTree =
  fmap (++ [WorkingTree]) . getCommitsBetween prevCommit
    =<< resolveRef "HEAD"
getCommitsBetween prevCommit@(Commit prevHash) commit@(Commit hash) = do
  catch
    (sh_ (proc "git merge-base --is-ancestor % %" prevHash hash))
    (\(_ :: E.ProcessException) -> throwIO (E.NoAncestor commit prevCommit))
  map (Commit . T.strip) . T.lines . T.decodeUtf8 . LB.toStrict
    <$> sh (proc "git log --format=%%H %..%" prevHash hash)

diffOf :: CommitHash -> CommitHash -> IO A.Patch
diffOf prevHash hash =
  A.parse . T.decodeUtf8 . LB.toStrict
    <$> sh (proc "git diff % %" (toTextUnsafe prevHash) (toTextUnsafe hash))