diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c99ca9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.stack-work +*.cabal diff --git a/README.md b/README.md new file mode 100644 index 0000000..3470caa --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# git-comtemplate (https://github.com/voidus/git-comtemplate) + +Small utility that prepares commit templates with a story id and co-authors. + +Running any command with `-h` or without any arguments shows a help page, except +`git comtemplate reset`, which will disable commit templates until another +command is run again. + +## Who is this project for? + +Let's say you or your team want git commit messages that adhere to a consistent +format. Typing it manually on each commit is error-prone and distracts you from +the actual commit message. + +Enter git-comtemplate. When you start working on a story, use +`git comtemplate story NICE-1` to set the story id and `git comtemplate authors +fh jd` and get coding. When you call `git commit` later on, your editor will be +pre-filled with the following message: + +``` +NICE-1: + +Co-authored-by: Finn the Human +Co-authored-by: Jake the Dog +``` + +There is even a space after the colon: If you're using vim, you can just press +A end start typing. + +*Slightest of Warnings*: `git-comtemplate` will overwrite your user +`commit.template` setting. If you are using this for something else, this +project might not work for you. Also, it will probably interact weirdly with +other git commit templating tools. + +## Author initials + +The mapping of initials that you can specify to `git comtemplate authors` has to +be maintained by hand. They are stored in `git-comtemplate/authors.dhall` in +your user config directory +([XDG_CONFIG_HOME](https://specifications.freedesktop.org/basedir-spec/basedir-spec-0.6.html), +should default to ~/.config/). +If that sounds confusing, don't worry: Running `git comtemplate` will show you +the full file names. + +The file is a [dhall config file](https://dhall-lang.org/), but don't worry, if +you saw json or wrote some code before it should look familiar. + +To get you started, `git comtemplate exampleAuthorsFile` will create the file +with some example data if it doesn't exist. + +## Building/Installing + +git-comtemplate is written in +[Haskell](https://qph.fs.quoracdn.net/main-qimg-086fb2e3079bd6fc4045d4da907fa4f5.webp). +If you want to learn about Haskell, [Learn You a Haskell for Great Good! +](http://learnyouahaskell.com/) is a nice introduction and can be read online +for free. + +This project uses [stack](https://haskellstack.org) as a build tool. Stack takes +care of downloading the compiler and all that stuff for us. It should +be available in major package repositories. Here are a few examples: + +
+
MacOS (with [homebrew](https://brew.sh/))
+
`brew install haskell-stack`
+
Nix
+
`nix-env -i stack`
+
Arch Linux
+
`pacman -S stack`
+
+ +When you have stack installed, open a shell in the project folder and execute +`stack build`. It takes a while on the first run, but that should be it! + +Stack puts the binary in a bit of a weird place. The following command prints +the full path to the binary: + +`echo "$PWD"/$(stack path --dist-dir)/build/git-comtemplate/git-comtemplate` + +Put it anywhere on your PATH and you will be able to call it as either +`git-comtemplate` or `git comtemplate` + +## FAQ + +
+
I changed my mind, how can I get rid of everything this did?
+
Run `git comtemplate`. In the text, it mentions two directories, one for + config (probably `~/.config/git-comtemplate`) and one for it's state + (probably `~/.local/share/git-comtemplate`). Run `git comtemplate reset` (or + unset the `commit.template` git setting yourself) and delete the two + `git-comtemplate` folders and nothing will remain.
+
This is way too much typing. Why are the names so long?
+
You can use `git comtemplate s` instead of `... story` as well as `a` + for `authors`. If you think `comtemplate` is too long, you can run `git + config --global alias.c comtemplate` and use `git c s STORY-42`. +
+ I personally don't bother: I only type this once or twice a day at most. + 🤷
+
This is great, but I want to use a different template
+
I am sure you have a valid reason, but I would rather not add the + complexity of supporting different templates. The problem isn't so much the + templating itself but the configuration around it. +
+ Right now, `git-comtemplate` has very narrowly defined code paths, and + I would prefer to keep it this way. Maybe you can fork this project? If you + are ready to put a few minutes in, open an issue and we'll talk about it. +
+ + +## Licensing + +This application is licensed under the GNU General Public License version 3 or +later. diff --git a/app/Main.hs b/app/Main.hs new file mode 100644 index 0000000..a59eac7 --- /dev/null +++ b/app/Main.hs @@ -0,0 +1,326 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Main + ( main, + ) +where + +import Control.Applicative ((<|>)) +import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Lazy as LBS +import Data.Either (partitionEithers) +import Data.List.NonEmpty (NonEmpty, nonEmpty) +import qualified Data.List.NonEmpty as NonEmpty +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import Data.Text (Text) +import qualified Data.Text as T +import qualified Data.Text.IO as TIO +import Data.Version (showVersion) +import qualified Dhall +import GHC.Generics (Generic) +import qualified Options.Applicative as O +import qualified Options.Applicative.Help.Pretty as Pretty +import Paths_git_comtemplate (version) +import System.Directory + ( XdgDirectory (XdgConfig, XdgData), + createDirectoryIfMissing, + doesFileExist, + getXdgDirectory, + removeFile, + ) +import System.Exit (ExitCode (ExitFailure), die) +import System.FilePath (()) +import System.Process (callCommand, createProcess, shell, waitForProcess) + +data Command + = CmdReset + | CmdAuthors [Text] + | CmdUnsetAuthors + | CmdStory Text + | CmdUnsetStory + | CmdCreateExampleAuthorsFile + deriving (Show) + +projectName :: String +projectName = "git-comtemplate" + +getConfigDir :: IO FilePath +getConfigDir = do + configDir <- getXdgDirectory XdgConfig projectName + createDirectoryIfMissing True configDir + return configDir + +getDataDir :: IO FilePath +getDataDir = do + dataDir <- getXdgDirectory XdgData projectName + createDirectoryIfMissing True dataDir + return dataDir + +getTemplateFilename :: IO FilePath +getTemplateFilename = + fmap ( "template") getDataDir + +getStateFilename :: IO FilePath +getStateFilename = + fmap ( "state") getDataDir + +data State + = State + { story :: Maybe Text, + authors :: [Author] + } + deriving (Generic, Aeson.FromJSON, Aeson.ToJSON) + +initialState :: State +initialState = State {story = Nothing, authors = []} + +exampleConfig :: Text +exampleConfig = + T.unlines + [ "[ { initials = \"gb\", expanded = \"Glimmer of Brightmoon \" }", + ", { initials = \"c\", expanded = \"Catra \" }", + "]" + ] + +readState :: IO State +readState = do + stateFilename <- getStateFilename + exists <- doesFileExist stateFilename + if exists + then do + contents <- LBS.readFile stateFilename + case Aeson.eitherDecode' contents of + Right state -> return state + Left err -> + die $ + unlines + [ "Error while reading " <> stateFilename <> ": " <> err, + "You can remove the file to reset the state.", + "If this error persists, please open an issue with git" + ] + else return initialState + +writeState :: State -> IO () +writeState state = do + stateFilename <- getStateFilename + Aeson.encodeFile stateFilename state + +command :: O.Parser Command +command = + let resetParser :: O.Parser Command + resetParser = pure CmdReset + resetInfo :: O.ParserInfo Command + resetInfo = O.info resetParser (O.progDesc "Remove the commit message template") + unsetAuthorsParser :: O.Parser Command + unsetAuthorsParser = + O.flag' + CmdUnsetAuthors + ( O.help "Unset the authors. This will remove the (co-)authored-by part from the template" + <> O.long "unset" + <> O.short 'u' + ) + setAuthorsParser :: O.Parser Command + setAuthorsParser = + let initialsArg = + O.strArgument + ( O.help "A list of initials that will be used as (co-)authors" + <> O.metavar "" + ) + in CmdAuthors <$> O.some initialsArg + authorsParser :: O.Parser Command + authorsParser = setAuthorsParser <|> unsetAuthorsParser + authorsInfo :: O.ParserInfo Command + authorsInfo = O.info authorsParser (O.progDesc "Set the authors") + unsetStoryParser :: O.Parser Command + unsetStoryParser = + O.flag' + CmdUnsetStory + ( O.help "Unset the story id" + <> O.long "unset" + <> O.short 'u' + ) + setStoryParser :: O.Parser Command + setStoryParser = + let storyArg = + O.strArgument + ( O.help "The story id that will be prepended to the first line" + <> O.metavar "" + ) + in CmdStory <$> storyArg + storyParser :: O.Parser Command + storyParser = setStoryParser <|> unsetStoryParser + storyInfo :: O.ParserInfo Command + storyInfo = O.info storyParser (O.progDesc "Set the story id") + exampleAuthorsFileParser :: O.Parser Command + exampleAuthorsFileParser = + pure CmdCreateExampleAuthorsFile + exampleAuthorsFileInfo :: O.ParserInfo Command + exampleAuthorsFileInfo = + O.info + exampleAuthorsFileParser + (O.progDesc "Create an example authors file (won't overwrite anything)") + in O.hsubparser + ( O.command "reset" resetInfo + <> O.command "authors" authorsInfo + <> O.command "story" storyInfo + <> O.command "exampleAuthorsFile" exampleAuthorsFileInfo + <> O.command "a" (O.info authorsParser $ O.progDesc "Alias for authors") + <> O.command "s" (O.info storyParser $ O.progDesc "Alias for story") + ) + +programInfo :: IO (O.InfoMod Command) +programInfo = do + footer <- helpFooter + return $ + O.fullDesc + <> O.progDesc "Prepares a git commit message template" + <> O.footerDoc (Just footer) + +configParser :: IO (O.ParserInfo Command) +configParser = + O.info (O.helper <*> command) <$> programInfo + +parseCommand :: IO Command +parseCommand = do + parser <- configParser + let prefs = + O.prefs + ( O.showHelpOnEmpty + <> O.disambiguate + ) + O.customExecParser prefs parser + +helpFooter :: IO Pretty.Doc +helpFooter = do + dataDir <- getDataDir + authorsFilename <- getAuthorsFilename + return + $ Pretty.string + $ unlines + [ "Running each command with -h or without any arguments will show more help text.", + "", + "Author initials are read from \"" <> authorsFilename <> "\".", + "It is expected to be a dhall file (https://dhall-lang.org/) containing a list of authors", + "to what should be used in the commit message.", + "", + "All files managed by " <> projectName <> " are placed in " <> dataDir <> ".", + "", + "This is " <> projectName <> " version " <> showVersion version <> ". ", + "", + "In case of bugs, weirdness or great ideas, create an issue at https://github.com/voidus/git-comtemplate" + ] + +data Author + = Author + { initials :: Text, + expanded :: Text + } + deriving (Generic, Dhall.Interpret, Aeson.FromJSON, Aeson.ToJSON) + +getAuthorsFilename :: IO FilePath +getAuthorsFilename = + fmap ( "authors.dhall") getConfigDir + +readAuthors :: IO (Map Text Author) +readAuthors = do + authorsFilename <- getAuthorsFilename + authorsList <- Dhall.inputFile Dhall.auto authorsFilename + return $ Map.fromList [(initials a, a) | a <- authorsList] + +main :: IO () +main = do + cmd <- parseCommand + oldState <- readState + runCommand oldState cmd + +updateTemplate :: State -> IO () +updateTemplate State {story, authors} = do + templateFilename <- getTemplateFilename + TIO.writeFile templateFilename contents + where + contents = + case nonEmpty authors of + Nothing -> storyLine + Just authors' -> T.unlines $ [storyLine, ""] <> authorLines authors' + storyLine = + case story of + Nothing -> "" + Just s -> s <> ": " + authorPrefix = + if length authors > 1 + then "Co-authored-by: " + else "Authored-by: " + authorLine author = authorPrefix <> expanded author + authorLines :: NonEmpty Author -> [Text] + authorLines = NonEmpty.toList . fmap authorLine + +setGitConfigOption :: IO () +setGitConfigOption = do + templateFilename <- getTemplateFilename + callCommand $ "git config --global commit.template " <> templateFilename + +unsetGitConfigOption :: IO () +unsetGitConfigOption = do + let commandLine = "git config --global --unset commit.template" + (_, _, _, handle) <- createProcess (shell commandLine) + exit <- waitForProcess handle + case exit of + ExitFailure code + | code /= 5 -> + error $ "\"" <> commandLine <> "\" returned unexpected error code " <> show code + _ -> pure () + +runCommand :: State -> Command -> IO () +runCommand _ CmdReset = do + unsetGitConfigOption + removeFile =<< getStateFilename + removeFile =<< getTemplateFilename +runCommand state (CmdStory story) = + applyState $ state {story = Just story} +runCommand state CmdUnsetStory = + applyState $ state {story = Nothing} +runCommand state (CmdAuthors selectedInitials) = do + availableAuthors <- readAuthors + let eitherMissingInitialOrAuthor = + [ maybeToEither i $ Map.lookup i availableAuthors + | i <- selectedInitials + ] + case partitionEithers eitherMissingInitialOrAuthor of + ([], authors) -> + applyState $ state {authors = authors} + (missingInitials, _) -> do + authorsFilename <- getAuthorsFilename + die $ + "I could not find the following initials in " <> authorsFilename <> ": " + <> T.unpack (T.intercalate ", " missingInitials) +runCommand _ CmdCreateExampleAuthorsFile = do + authorsFilename <- getAuthorsFilename + exists <- doesFileExist authorsFilename + if exists + then do + putStrLn $ + "The authors file (" <> authorsFilename <> ") already exists and \n" + <> "I'm afraid that I might break something if I overwrite it, so I won't 🐥" + putStrLn "" + putStrLn "If you need help with the syntax, here is what I would have put in there:" + putStrLn "" + TIO.putStrLn exampleConfig + else do + TIO.writeFile authorsFilename exampleConfig + putStrLn $ "I wrote an example config to " <> authorsFilename <> ". I hope that helps 👽" +runCommand state CmdUnsetAuthors = + applyState $ state {authors = []} + +applyState :: State -> IO () +applyState state = do + writeState state + updateTemplate state + setGitConfigOption + +maybeToEither :: a -> Maybe b -> Either a b +maybeToEither _ (Just r) = Right r +maybeToEither l Nothing = Left l diff --git a/package.yaml b/package.yaml new file mode 100644 index 0000000..34fd527 --- /dev/null +++ b/package.yaml @@ -0,0 +1,45 @@ +name: git-comtemplate +version: 1 +license: GPL-3.0-or-later + +dependencies: +- base >= 4 && < 5 +- optparse-applicative ^>= 0.14 +- text ^>= 1.2 +- dhall ^>= 1.24 +- containers ^>= 0.6 +- process ^>= 1.6 +- aeson ^>= 1.4 +- bytestring ^>= 0.10 +- directory ^>= 1.3 +- filepath ^>= 1.4 + +executable: + main: Main.hs + source-dirs: app + +# library: +# source-dirs: src +# +# tests: +# spec: +# main: Spec +# source-dirs: test +# dependencies: +# - git-comtemplate +# - hspec ^>= 2.7 + +ghc-options: +# For details on warnings: https://downloads.haskell.org/~ghc/master/users-guide/using-warnings.html +# This list taken from https://medium.com/mercury-bank/enable-all-the-warnings-a0517bc081c3 +# Enable all warnings with -Weverything, then disable the ones we don’t care about +- -Weverything +- -Wno-missing-exported-signatures # missing-exported-signatures turns off the more strict -Wmissing-signatures. See https://ghc.haskell.org/trac/ghc/ticket/14794#ticket +- -Wno-missing-import-lists # Requires explicit imports of _every_ function (e.g. ‘$’); too strict +- -Wno-missed-specialisations # When GHC can’t specialize a polymorphic function. No big deal and requires fixing underlying libraries to solve. +- -Wno-all-missed-specialisations # See missed-specialisations +- -Wno-unsafe # Don’t use Safe Haskell warnings +- -Wno-safe # Don’t use Safe Haskell warnings +- -Wno-missing-local-signatures # Warning for polymorphic local bindings; nothing wrong with those. +- -Wno-monomorphism-restriction # Don’t warn if the monomorphism restriction is used +- -Wno-implicit-prelude # don't warn on implicit prelude import diff --git a/stack.yaml b/stack.yaml new file mode 100644 index 0000000..d0edc0e --- /dev/null +++ b/stack.yaml @@ -0,0 +1,4 @@ +resolver: lts-14.16 + +packages: + - . diff --git a/stack.yaml.lock b/stack.yaml.lock new file mode 100644 index 0000000..5e17e80 --- /dev/null +++ b/stack.yaml.lock @@ -0,0 +1,12 @@ +# This file was autogenerated by Stack. +# You should not edit this file by hand. +# For more information, please see the documentation at: +# https://docs.haskellstack.org/en/stable/lock_files + +packages: [] +snapshots: +- completed: + size: 524804 + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/14/16.yaml + sha256: 4d1519a4372d051d47a5eae2241cf3fb54e113d7475f89707ddb6ec06add2888 + original: lts-14.16