packages feed

hit-on-0.1.0.0: src/Hit/Git/Status.hs

{- | Data types that describe model of the @git diff@ command to display diffs
in pretty way.
-}

module Hit.Git.Status
       ( showPrettyDiff
       ) where

import Shellmet (($?), ($|))
import System.Process (callCommand)

import Hit.ColorTerminal (blueCode, boldCode, cyanCode, greenCode, magentaCode, redCode, resetCode,
                          yellowCode)

import qualified Data.Text as T


-- | Enum that represents all possible types of file modifications.
data PatchType
    = Added
    | Copied
    | Deleted
    | Modified
    | Renamed
    | TypeChanged
    | Unmerged
    | Unknown
    | BrokenPairing

-- | Map conventional characters to 'PatchType'
parsePatchType :: Text -> Maybe PatchType
parsePatchType = \case
    "A" -> Just Added
    "C" -> Just Copied
    "D" -> Just Deleted
    "M" -> Just Modified
    "R" -> Just Renamed
    "T" -> Just TypeChanged
    "U" -> Just Unmerged
    "X" -> Just Unknown
    "B" -> Just BrokenPairing
    _   -> Nothing

-- | Display 'PatchType' in colorful and expanded text.
displayPatchType :: PatchType -> Text
displayPatchType = \case
    Added         -> coloredIn greenCode   "added"
    Copied        -> coloredIn blueCode    "copied"
    Deleted       -> coloredIn redCode     "deleted"
    Modified      -> coloredIn magentaCode "modified"
    Renamed       -> coloredIn yellowCode  "renamed"
    TypeChanged   -> coloredIn cyanCode    "type-changed"
    Unmerged      -> inBold                "unmerged"
    Unknown       -> inBold                "unknown"
    BrokenPairing -> inBold                "broken"
  where
    coloredIn :: Text -> Text -> Text
    coloredIn color text = color <> inBold text

    inBold :: Text -> Text
    inBold text = boldCode <> text <> resetCode

-- | Output of the @git diff --name-status@ command.
data DiffName = DiffName
    { diffNameFile :: !Text       -- ^ file name
    , diffNameType :: !PatchType  -- ^ type of the changed file
    }

parseDiffName :: [Text] -> Maybe DiffName
parseDiffName [t, name] = DiffName name <$> parsePatchType t
parseDiffName _         = Nothing

-- | Output of the @git diff --stat@ command.
data DiffStat = DiffStat
    { diffStatFile  :: !Text  -- ^ file name
    , diffStatCount :: !Text  -- ^ number of changed lines
    , diffStatSigns :: !Text  -- ^ + and - stats
    }

{- | This command parses diff stats in the following format:

@
<filename> | <n> <pluses-and-minuses>
@

It also handles special case of binary files. Typical raw text returned by @git@
can look like this:

@
 .foo.un~  | Bin 0 -> 523 bytes
 README.md |   4 ++++
 foo       |   1 +
@
-}
parseDiffStat :: [Text] -> Maybe DiffStat
parseDiffStat = \case
    [diffStatFile, diffStatCount, diffStatSigns] -> Just DiffStat{..}
    diffStatFile:"Bin":rest -> Just DiffStat
        { diffStatCount = "Bin"
        , diffStatSigns = unwords rest
        , ..
        }
    _ -> Nothing

showPrettyDiff :: Text -> IO ()
showPrettyDiff commit = do
    -- 1. Check rebase in progress and tell about it
    whenM isRebaseInProgress $ do
        putTextLn gitRebaseHelp
        showConlictFiles

    -- 2. Output pretty diff
    diffName <- map words   . lines <$> "git" $| ["diff", commit, "--name-status"]
    diffStat <- map toStats . lines <$> "git" $| ["diff", commit, "--stat", "--color=always"]
    let fileTypes = sortWith diffNameFile $ mapMaybe parseDiffName diffName
    let fileStats = sortWith diffStatFile $ mapMaybe parseDiffStat diffStat
    let rows = zipWith joinDiffs fileTypes fileStats
    putText $ formatTableAligned rows
  where
    toStats :: Text -> [Text]
    toStats = foldMap words . T.split (== '|')

    joinDiffs :: DiffName -> DiffStat -> (Text, Text, Text, Text)
    joinDiffs DiffName{..} DiffStat{..} =
        (displayPatchType diffNameType, diffNameFile, diffStatCount, diffStatSigns)

    formatTableAligned :: [(Text, Text, Text, Text)] -> Text
    formatTableAligned rows = unlines $ map formatRow rows
      where
        formatRow :: (Text, Text, Text, Text) -> Text
        formatRow (fileType, fileName, fileCount, fileSigns) =
            padRight typeSize fileType
            <> "  "
            <> padRight nameSize fileName
            <> " | "
            <> padLeft countSize fileCount
            <> " "
            <> fileSigns

        padRight :: Int -> Text -> Text
        padRight n t = t <> T.replicate (n - T.length t) " "

        padLeft :: Int -> Text -> Text
        padLeft n t = T.replicate (n - T.length t) " " <> t

        typeSize, nameSize :: Int
        typeSize  = maxOn (\(a, _, _, _) -> a) rows
        nameSize  = maxOn (\(_, b, _, _) -> b) rows
        countSize = maxOn (\(_, _, c, _) -> c) rows

        maxOn :: (a -> Text) -> [a] -> Int
        maxOn f = foldl' (\acc a -> max acc $ T.length $ f a) 0

{- | Returns 'True' if rebase is in progress. Calls magic comand and if this
command exits with code 1 then there's no rebase in progress.
-}
isRebaseInProgress :: IO Bool
isRebaseInProgress = do
    let checkRebaseCmd = callCommand "ls `git rev-parse --git-dir` | grep rebase > /dev/null 2>&1"
    True <$ checkRebaseCmd $? pure False

gitRebaseHelp :: Text
gitRebaseHelp = unlines
    [ ""
    , boldCode <> yellowCode <> "Rebase in progress! What you can do:" <> resetCode
    , "    " <> cyanCode <> "git rebase --continue " <> resetCode <> ": after fixing conflicts"
    , "    " <> cyanCode <> "git rebase --skip     " <> resetCode <> ": to skip this patch"
    , "    " <> cyanCode <> "git rebase --abort    " <> resetCode <> ": to abort to the original branch"
    ]

showConlictFiles :: IO ()
showConlictFiles = do
    conflictFiles <- lines <$> "git" $| ["diff", "--name-only", "--diff-filter=U"]
    unless (null conflictFiles) $
        putTextLn $ unlines $
            ( boldCode <> redCode <> "Conflict files:" <> resetCode )
            : map ("    " <>) conflictFiles