--
-- Copyright (c) 2006 Don Stewart - http://www.cse.unsw.edu.au/~dons
-- GPL version 2 or later (see http://www.gnu.org/copyleft/gpl.html)
--
--
-- darcs-graph:
-- generate .png graphs of commit activity of a darcs repository,
-- including a 7 day sliding average
--
-- requires gnuplot
--
import Data.List
import Data.Maybe
import qualified Data.IntMap as I
import Data.ByteString.Char8 (ByteString,append,pack,unpack)
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString as B
import Control.Exception (bracket,handle)
import Control.Monad (when)
import Foreign.C.Error
import Foreign.Storable (peek)
import Foreign.Ptr (Ptr, nullPtr)
import Foreign.Marshal (alloca)
import System.Console.GetOpt
import System.Environment (getArgs)
import System.Posix.Internals
import System.IO.Error (modifyIOError, ioeSetFileName)
import System.IO
import System.IO.Unsafe
import System.Time
import System.Directory
import System.Process
tmp :: FilePath
tmp = unsafePerformIO $ getTemporaryDirectory
data Flag = DateFlag String | YFlag Int | Help | OFile FilePath | PName String
deriving (Show,Eq)
options :: [OptDescr Flag]
options = [
Option ['f'] ["filter"] (ReqArg (\s -> DateFlag s) "DATE")
"Don't display patches before date. Date is in format \"yyyymmdd\"",
Option ['y'] ["y"] (ReqArg (\s -> YFlag (read s)) "INT")
"Vertical scale of graphs. Integer.",
Option ['o'] ["output"] (ReqArg (\s -> OFile s) "FILE")
"Filename to write output to.",
Option ['n'] ["name"] (ReqArg (\s -> PName s) "NAME")
"Name of project used in generated graph.",
Option ['h'] ["help"] (NoArg Help)
"Display this help"
]
usage :: String
usage = usageInfo "usage: darcs-graph [options] repo\n" options
readOptions :: (Monad m) => [String] -> m ([Flag], [String])
readOptions args =
case getOpt Permute options args of
(o, n, []) -> return (o, n)
(_, _, errs) -> fail $ unlines errs
getFilter :: [Flag] -> Maybe ByteString
getFilter l =
let l' = [s | (DateFlag s) <- l]
in if null l' then Nothing else Just . pack . head $ l'
getYFlag :: [Flag] -> Maybe Int
getYFlag l =
let l' = [i | (YFlag i) <- l]
in if null l' then Nothing else Just . head $ l'
getOFile :: [Flag] -> String -> FilePath
getOFile flags proj = case [ s | OFile s <- flags ] of
[] -> tmp </> proj ++ "-commits.png"
(fn:_) -> fn
getPName :: [Flag] -> String -> String
getPName flags proj = case [ s | PName s <- flags ] of
[] -> proj
(fn:_) -> fn
data Range = Small | Big | Normal
deriving Eq
-- let's go
main :: IO ()
main = do
args <- getArgs
(flags, remaining) <- readOptions args
when (null remaining || length remaining > 1 || Help `elem` flags) $ error usage
let path = pack . head $ remaining
mfilter = getFilter flags
mymax = getYFlag flags
-- aquire some temp files, make sure to clean them up afterwards
bracket
(do x <- openTempFile tmp "darcs-graph.data"
y <- openTempFile tmp "darcs-graph-average.data"
return (x,y))
(\((f,_),(g,_)) -> removeFile f >> removeFile g) $ \((f,h),(g,i)) -> do
-- read patches dir, count patch dates, and write to temp file
ps <- readDir (path `append` pack "/_darcs/patches/")
let stats' = uniq . sort . map (C.take 8) . filter ignore $ ps
-- filter out stats earlier than 'yyyymmdd'
let stats | (Just date) <- mfilter = filter (\(x,_) -> x > date) stats'
| otherwise = stats'
when (null stats) $ error $ "no patches found in: "++unpack path
(C.hPutStr h . C.unlines . map fmt) stats >> hClose h
-- now work out the sliding average, and write it out too
let days = enumDates (toClock . fst . head $ stats) (toClock . fst . last $ stats)
avrg = sliding (map readFst stats) (map toInt days)
(C.hPutStr i . C.unlines . map fmt') avrg >> hClose i
-- now run gnuplot on the result
let proj = (unpack . basename $ path)
out = getOFile flags proj
script = gnuplotScript f -- handle small repos
-- can't generate average with less than 7 points,
-- and the sliding line needs 3 points to plot csplines
(if length days >= window && length avrg >= 3
then Just g
else Nothing)
(if length days < 120
then Small
else if length days > 2500
then Big
else Normal) -- only print days or months if range is ok
(getPName flags proj)
mymax
out
(ih,_,eh,pid) <- handle
(\e -> error $ "Couldn't fork gnuplot: " ++ show e)
(do mgnu <- findExecutable "gnuplot"
case mgnu of
Nothing -> error "Cannot find gnuplot"
Just gnuplot -> runInteractiveCommand gnuplot)
hPutStrLn ih script >> hClose ih -- write into gnuplot
hGetContents eh >>= putStr -- dump any error output it produces
waitForProcess pid
putStrLn $ "Output written to: " ++ out
where
readFst (a,b) = ((fst . fromJust . C.readInt) a, b)
uniq = map (\n -> (head n, length $ n)) . group
fmt (s,n) = joinWithSpace s (pack . show $ n)
fmt' (n,d) = joinWithSpace (pack.show $ n) (pack.show $ d)
ignore f = f /= pack "pending"
&& f /= pack "unrevert"
&& not (pack "." `C.isPrefixOf` f)
joinWithSpace s t = B.intercalate (B.singleton 32) [s,t]
------------------------------------------------------------------------
-- Dealing with clock times
--
-- enumerate all the days, as ClockTimes, between the two dates
--
enumDates :: ClockTime -> ClockTime -> [ClockTime]
enumDates d0 dn = map (\d -> addToClockTime (TimeDiff 0 0 d 0 0 0 0) d0) [0 .. days]
where
t = diffClockTimes dn d0
days = floor (((fromIntegral $ tdSec t) / 60 / 60 / 24) :: Double)
--
-- convert a date packed in a bytestring to a ClockTime
-- yyyymmdd format.
--
toClock :: ByteString -> ClockTime
toClock x = toClockTime $ CalendarTime {
ctYear = y,
ctMonth = toEnum (m-1) :: Month,
ctDay = d,
ctHour = 0, ctMin = 0, ctSec = 0, ctPicosec = 0,
ctWDay = undefined, ctYDay = undefined,
ctTZName = "GMT", ctTZ = 0 , ctIsDST = False
}
where (y,m,d) = split x
-- read the bytestring as a triple of yyyymmdd
split s = let a = fst . fromJust . C.readInt . C.take 4 $ s
b = fst . fromJust . C.readInt . C.drop 4 . C.take 6 $ s
c = fst . fromJust . C.readInt . C.drop 6 . C.take 8 $ s
in (a,b,c)
--
-- convert a ClockTime back to an Int in yyyymmdd format
--
toInt :: ClockTime -> Int
toInt ct = read $ show yyyy ++ show0 mm ++ show0 dd
where yyyy = ctYear cl
mm = fromEnum (ctMonth cl) + 1
dd = ctDay cl
cl = unsafePerformIO (toCalendarTime ct)
show0 n | n < 10 && n >= 0 = '0' : show n
| otherwise = show n
-- sliding average window
window :: Int
window = 7
--
-- compute the 7-day sliding average, starting with a sparse sequence
--
sliding :: [(Int,Int)] -> [Int] -> [(Int,Double)]
sliding [] _ = []
sliding xs days = [ (i,slidingAv i) | i <- days' ]
where
m = window `div` 2 -- 1/2 n
days' = drop m . take (max 0 (length days - window+m+1)) $ days
table = I.fromList xs :: I.IntMap Int -- to handle missing elems easily
slidingAv :: Int -> Double
slidingAv i = fromIntegral (sum seqN) / fromIntegral window
where seqN = map (\j -> I.findWithDefault 0 j table) [i-m..i+(window-m-1)]
--
-- generate the gnuplot script. only generate the avergage line, if
-- there repo day range is larger than a window
--
gnuplotScript :: FilePath -> Maybe FilePath -> Range -> String -> Maybe Int -> FilePath -> String
gnuplotScript f mg range p mymax outFile = header ++ case mg of
Nothing -> "plot \""++f++"\" using 1:2 with points pointsize 0.5 pointtype 2\n"
Just g -> "plot \"" ++
f++"\" using 1:2 with points pointsize 0.5 pointtype 2, \"" ++
g++"\" using 1:2 smooth csplines 2\n"
where header = unlines
["set xdata time"
,"set timefmt \"%Y%m%d\""
,case range of
Small -> "set format x\"%y%m%d\""
Normal -> "set format x\"%y%m\""
Big -> "set format x\"%y\""
,"set size 1,0.4"
,"set mxtics 0"
,"set xtics nomirror"
,"set ytics nomirror"
,"set border 3"
,"set yrange [0:" ++ (if isNothing mymax then "*" else show . fromJust $ mymax) ++ "]"
,"set title \""++p++" commits per day and 7-day sliding average\""
,"unset key"
,"set ylabel \"commits per day\""
-- To use a TTF font, set the GDFONTPATH=/home/dons/TTF, and uncomment
-- this line:
-- ,"set terminal png font Vera 9 xffffff x000000 x404040 xd0d0d0 x0000ff"
,"set terminal png small xffffff x000000 x404040 xd0d0d0 x0000ff"
,"set output \"" ++ outFile ++ "\""]
--
-- ByteString utilities
--
-- | Packed string version of basename
basename :: ByteString -> ByteString
basename fps = case C.elemIndexEnd '/' s of
Nothing -> fps
Just i -> C.drop (i+1) s
where s = C.reverse . C.dropWhile (=='/') . C.reverse $ fps
--
-- | Packed version of get directory contents. super fast
--
readDir :: ByteString -> IO [ByteString]
readDir path = do
modifyIOError (`ioeSetFileName` (unpack path)) $
alloca $ \ ptr_dEnt ->
bracket
(C.useAsCString path $ \s ->
throwErrnoIfNullRetry desc (c_opendir s))
(\p -> throwErrnoIfMinus1_ desc (c_closedir p))
(\p -> loop ptr_dEnt p)
where
desc = "readDir"
loop :: Ptr (Ptr CDirent) -> Ptr CDir -> IO [ByteString]
loop ptr_dEnt dir = do
resetErrno
r <- readdir dir ptr_dEnt
if (r == 0)
then do dEnt <- peek ptr_dEnt
if (dEnt == nullPtr)
then return []
else do -- copy entry out before we free:
entry <- C.packCString =<< d_name dEnt
C.length entry `seq` return () -- strictify
freeDirEnt dEnt
entries <- loop ptr_dEnt dir
return $! (entry:entries)
else do errno <- getErrno
if (errno == eINTR)
then loop ptr_dEnt dir
else do let (Errno eo) = errno
if (eo == end_of_dir)
then return []
else throwErrno desc
-- useful
infixr 6 </>
(</>) :: FilePath -> FilePath -> FilePath
[] </> b = b
a </> b = a ++ "/" ++ b