niv-0.3.0: src/Niv/Git/Cmd.hs
{-# LANGUAGE Arrows #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE ViewPatterns #-}
module Niv.Git.Cmd where
import Control.Arrow
import Control.Monad.Except (throwError)
import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KM
import Data.Char (isDigit)
import qualified Data.HashMap.Strict as HMS
import qualified Data.Text as T
import Niv.Cmd
import Niv.Sources
import Niv.Update
import System.Exit (ExitCode (ExitSuccess))
import System.Process (readProcessWithExitCode)
import UnliftIO
gitCmd :: Cmd
gitCmd =
Cmd
{ parseCmdShortcut = parseGitShortcut,
updateCmd = gitUpdate',
name = "git",
extraLogs = gitExtraLogs,
acceptsCmd = \(unPackageSpec -> spec) -> KM.lookup "type" spec == Just "git"
}
gitExtraLogs :: Attrs -> [T.Text]
gitExtraLogs attrs = noteRef <> warnRefBranch <> warnRefTag
where
noteRef =
textIf (HMS.member "ref" attrs) $
"Your source contains a `ref` attribute. Make sure your sources.nix is up-to-date and consider using a `branch` or `tag` attribute."
warnRefBranch =
textIf (member "ref" && member "branch") $
"Your source contains both a `ref` and a `branch`. Niv will update the `branch` but the `ref` will be used by Nix to fetch the repo."
warnRefTag =
textIf (member "ref" && member "tag") $
"Your source contains both a `ref` and a `tag`. The `ref` will be used by Nix to fetch the repo."
member x = HMS.member x attrs
textIf cond txt = [txt | cond]
parseGitShortcut :: T.Text -> Maybe (PackageName, PackageSpec)
parseGitShortcut txt'@(T.dropWhileEnd (== '/') -> txt) =
second PackageSpec
<$>
-- basic heuristics for figuring out if something is a git repo
if isGitURL
then case T.splitOn "/" txt of
[] -> Nothing
(last -> w) -> case T.stripSuffix ".git" w of
Nothing -> Just (PackageName w, KM.fromList ["repo" .= txt', "type" .= Aeson.String "git"])
Just w' -> Just (PackageName w', KM.fromList ["repo" .= txt', "type" .= Aeson.String "git"])
else Nothing
where
isGitURL =
".git"
`T.isSuffixOf` txt
|| "git@"
`T.isPrefixOf` txt
|| "ssh://"
`T.isPrefixOf` txt
gitUpdate ::
-- | latest rev
(T.Text -> T.Text -> IO T.Text) ->
-- | latest rev and default ref
(T.Text -> IO (T.Text, T.Text)) ->
Update () ()
gitUpdate latestRev' defaultBranchAndRev' = proc () -> do
useOrSet "type" -< ("git" :: Box T.Text)
repository <- load "repo" -< ()
discoverRev <+> discoverRefAndRev -< repository
where
discoverRefAndRev = proc repository -> do
branchAndRev <- run defaultBranchAndRev' -< repository
update "branch" -< fst <$> branchAndRev
update "rev" -< snd <$> branchAndRev
returnA -< ()
discoverRev = proc repository -> do
branch <- load "branch" -< ()
rev <- run' (uncurry latestRev') -< (,) <$> repository <*> branch
update "rev" -< rev
returnA -< ()
-- | The "real" (IO) update
gitUpdate' :: Update () ()
gitUpdate' = gitUpdate latestRev defaultBranchAndRev
latestRev ::
-- | the repository
T.Text ->
-- | the branch
T.Text ->
IO T.Text
latestRev repo branch = do
let gitArgs = ["ls-remote", repo, "refs/heads/" <> branch]
sout <- runGit gitArgs
case sout of
ls@(_ : _ : _) -> abortTooMuchOutput gitArgs ls
[l1] -> parseRev gitArgs l1
[] -> abortNoOutput gitArgs
where
parseRev args l = maybe (abortNoRev args l) pure $ do
checkRev $ T.takeWhile (/= '\t') l
checkRev t = if isRev t then Just t else Nothing
abortNoOutput args =
abortGitFailure
args
$ "Git didn't produce any output. Does the branch '" <> branch <> "' exist?"
abortTooMuchOutput args ls =
abortGitBug args $
T.unlines $
["Git produced too much output:"] <> map (" " <>) ls
defaultBranchAndRev ::
-- | the repository
T.Text ->
IO (T.Text, T.Text)
defaultBranchAndRev repo = do
sout <- runGit args
case sout of
(l1 : l2 : _) -> (,) <$> parseBranch l1 <*> parseRev l2
_ ->
abortGitBug args $
T.unlines $
[ "Could not read reference and revision from stdout:"
]
<> sout
where
args = ["ls-remote", "--symref", repo, "HEAD"]
parseBranch l = maybe (abortNoRef args l) pure $ do
-- ref: refs/head/master\tHEAD -> master\tHEAD
refAndSym <- T.stripPrefix "ref: refs/heads/" l
let branch = T.takeWhile (/= '\t') refAndSym
if T.null branch then Nothing else Just branch
parseRev l = maybe (abortNoRev args l) pure $ do
checkRev $ T.takeWhile (/= '\t') l
checkRev t = if isRev t then Just t else Nothing
abortNoRev :: [T.Text] -> T.Text -> IO a
abortNoRev args l = abortGitBug args $ "Could not read revision from: " <> l
abortNoRef :: [T.Text] -> T.Text -> IO a
abortNoRef args l = abortGitBug args $ "Could not read reference from: " <> l
-- | Run the "git" executable
runGit :: [T.Text] -> IO [T.Text]
runGit args = do
(exitCode, sout, serr) <- readProcessWithExitCode "git" (T.unpack <$> args) ""
case (exitCode, lines sout) of
(ExitSuccess, ls) -> pure $ T.pack <$> ls
_ ->
abortGitBug args $
T.unlines
[ T.unwords ["stdout:", T.pack sout],
T.unwords ["stderr:", T.pack serr]
]
isRev :: T.Text -> Bool
isRev t =
-- commit hashes are comprised of abcdef0123456789
T.all (\c -> (c >= 'a' && c <= 'f') || isDigit c) t
&&
-- commit _should_ be 40 chars long, but to be sure we pick 7
T.length t >= 7
abortGitFailure :: [T.Text] -> T.Text -> IO a
abortGitFailure args msg =
abort $
T.unlines
[ "Could not read the output of 'git'.",
T.unwords ("command:" : "git" : args),
msg
]
abortGitBug :: [T.Text] -> T.Text -> IO a
abortGitBug args msg =
abort $
bug $
T.unlines
[ "Could not read the output of 'git'.",
T.unwords ("command:" : "git" : args),
msg
]
abort :: (MonadIO io) => T.Text -> io a
abort msg =
liftIO $ throwError $ userError $ T.unpack msg