diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,4 @@
+# Revision history for cli-git
+
+## 0.1.0.0
+* Initial release
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2020 Obsidian Systems LLC
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Obsidian Systems LLC nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/cli-git.cabal b/cli-git.cabal
new file mode 100644
--- /dev/null
+++ b/cli-git.cabal
@@ -0,0 +1,30 @@
+cabal-version: >=1.10
+name: cli-git
+version: 0.1.0.0
+license: BSD3
+license-file: LICENSE
+copyright: Obsidian Systems LLC 2020
+maintainer: maintainer@obsidian.systems
+author: Obsidian Systems LLC
+synopsis: Bindings to the git command-line interface
+category: Git, Bindings
+build-type: Simple
+extra-source-files:
+    CHANGELOG.md
+
+library
+    exposed-modules:
+        Bindings.Cli.Git
+    hs-source-dirs: src
+    default-language: Haskell2010
+    build-depends:
+        base ==4.12.*,
+        cli-extras >=0.1.0.0 && <0.2,
+        containers >=0.6.0.1 && <0.7,
+        data-default >=0.7.1.1 && <0.8,
+        exceptions >=0.10.3 && <0.11,
+        lens >=4.17.1 && <4.18,
+        logging-effect >=1.3.4 && <1.4,
+        megaparsec >=7.0.5 && <7.1,
+        mtl >=2.2.2 && <2.3,
+        text >=1.2.3.1 && <1.3
diff --git a/src/Bindings/Cli/Git.hs b/src/Bindings/Cli/Git.hs
new file mode 100644
--- /dev/null
+++ b/src/Bindings/Cli/Git.hs
@@ -0,0 +1,209 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PatternGuards #-}
+{-# LANGUAGE TemplateHaskell #-}
+module Bindings.Cli.Git
+  ( CommitId
+  , gitProc
+  , ensureCleanGitRepo
+  , readGitProcess
+  , isolateGitProc
+  , gitProcNoRepo
+  , gitLsRemote
+  , gitLookupDefaultBranch
+  , gitLookupCommitForRef
+  , GitRef (..)
+  ) where
+
+import Control.Applicative hiding (many)
+import Control.Monad.Catch (MonadMask)
+import Control.Monad.Except
+import Control.Monad.Fail
+import Control.Monad.Log
+import Data.Bool (bool)
+import Data.Bifunctor
+import Data.Char
+import Data.Either
+import Data.Map (Map)
+import qualified Data.Map as M
+import Data.Maybe (maybeToList)
+import Data.Semigroup ((<>))
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Void (Void)
+import System.Exit (ExitCode)
+import qualified Text.Megaparsec.Char.Lexer as ML
+import Text.Megaparsec as MP
+import Text.Megaparsec.Char as MP
+
+import Cli.Extras
+
+-- Check whether the working directory is clean
+checkGitCleanStatus
+  :: ( MonadIO m
+     , MonadLog Output m
+     , MonadError e m
+     , AsProcessFailure e
+     , MonadFail m
+     )
+  => FilePath
+  -> Bool
+  -> m Bool
+checkGitCleanStatus repo withIgnored = do
+  let
+    runGit = readProcessAndLogStderr Debug . gitProc repo
+    gitStatus = runGit $ ["status", "--porcelain"] <> bool [] ["--ignored"] withIgnored
+    gitDiff = runGit ["diff"]
+  T.null <$> liftA2 (<>) gitStatus gitDiff
+
+-- | Ensure that git repo is clean
+ensureCleanGitRepo
+  :: ( MonadIO m
+     , MonadLog Output m
+     , MonadError e m
+     , AsProcessFailure e
+     , MonadFail m
+     , HasCliConfig m
+     , MonadMask m
+     , AsUnstructuredError e
+     )
+  => FilePath
+  -> Bool
+  -> Text
+  -> m ()
+ensureCleanGitRepo path withIgnored s =
+  withSpinnerNoTrail ("Ensuring clean git repo at " <> T.pack path) $ do
+    checkGitCleanStatus path withIgnored >>= \case
+      False -> do
+        statusDebug <- readGitProcess path $ ["status"] <> bool [] ["--ignored"] withIgnored
+        putLog Warning "Working copy is unsaved; git status:"
+        putLog Notice statusDebug
+        failWith s
+      True -> pure ()
+
+gitProcNoRepo :: [String] -> ProcessSpec
+gitProcNoRepo args = setEnvOverride (M.singleton "GIT_TERMINAL_PROMPT" "0" <>) $ proc "git" args
+
+gitProc :: FilePath -> [String] -> ProcessSpec
+gitProc repo = gitProcNoRepo . runGitInDir
+  where
+    runGitInDir args' = case filter (not . null) args' of
+      args@("clone":_) -> args <> [repo]
+      args -> ["-C", repo] <> args
+
+isolateGitProc :: ProcessSpec -> ProcessSpec
+isolateGitProc = setEnvOverride (overrides <>)
+  where
+    overrides = M.fromList
+      [ ("HOME", "/dev/null")
+      , ("GIT_CONFIG_NOSYSTEM", "1")
+      , ("GIT_TERMINAL_PROMPT", "0") -- git 2.3+
+      , ("GIT_ASKPASS", "echo") -- pre git 2.3 to just use empty password
+      , ("GIT_SSH_COMMAND", "ssh -o PreferredAuthentications password -o PubkeyAuthentication no -o GSSAPIAuthentication no")
+      ]
+
+readGitProcess
+  :: ( MonadIO m
+     , MonadLog Output m
+     , MonadError e m
+     , AsProcessFailure e
+     , MonadFail m
+     )
+  => FilePath -> [String] -> m Text
+readGitProcess repo = readProcessAndLogOutput (Debug, Notice) . gitProc repo
+
+gitLookupDefaultBranch :: GitLsRemoteMaps -> Either Text Text
+gitLookupDefaultBranch (refs, _) = do
+  ref <- case M.lookup GitRef_Head refs of
+    Just ref -> pure ref
+    Nothing -> throwError
+      "No symref entry for HEAD. \
+      \ Is your git version at least 1.8.5? \
+      \ Otherwise `git ls-remote --symref` will not work."
+  case ref of
+    GitRef_Branch b -> pure b
+    _ -> throwError $
+      "Default ref " <> showGitRef ref <> " is not a branch!"
+
+gitLookupCommitForRef :: GitLsRemoteMaps -> GitRef -> Either Text CommitId
+gitLookupCommitForRef (_, commits) ref = case M.lookup ref commits of
+  Just a -> pure a
+  Nothing -> throwError $ "Did not find commit for " <> showGitRef ref
+
+gitLsRemote
+  :: ( MonadIO m
+     , MonadLog Output m
+     , MonadError e m
+     , AsProcessFailure e
+     , MonadFail m
+     , AsUnstructuredError e
+     )
+  => String
+  -> Maybe GitRef
+  -> Maybe String
+  -> m (ExitCode, GitLsRemoteMaps)
+gitLsRemote repository mRef mBranch = do
+  (exitCode, out, _err) <- case mBranch of
+    Nothing -> readCreateProcessWithExitCode $ gitProcNoRepo $
+        ["ls-remote", "--exit-code", "--symref", repository]
+        ++ maybeToList (T.unpack . showGitRef <$> mRef)
+    Just branchName -> readCreateProcessWithExitCode $ gitProcNoRepo
+        ["ls-remote", "--exit-code", repository, branchName]
+  let t = T.pack out
+  maps <- case MP.runParser parseLsRemote "" t of
+    Left err -> failWith $ T.pack $ MP.errorBundlePretty err
+    Right table -> pure $ bimap M.fromList M.fromList $ partitionEithers table
+  putLog Debug $ "git ls-remote maps: " <> T.pack (show maps)
+  pure (exitCode, maps)
+
+lexeme :: Parsec Void Text a -> Parsec Void Text a
+lexeme = ML.lexeme $ void $ MP.takeWhileP (Just "within-line white space") $
+  flip elem [' ', '\t']
+
+-- $ git ls-remote --symref git@github.com:obsidiansystems/obelisk.git HEAD
+-- ref: refs/heads/master	HEAD
+-- d0a8d25dc93f0acd096bc4ff2f550da9e2d0c8f5	refs/heads/master
+parseLsRemote :: Parsec Void Text [Either (GitRef, GitRef) (GitRef, CommitId)]
+parseLsRemote =
+  many ((fmap Left (try parseRef) <|> fmap Right parseCommit) <* try MP.eol) <* MP.eof
+  where
+    parseRef :: Parsec Void Text (GitRef, GitRef)
+    parseRef = MP.label "ref and symbolic ref" $ do
+      _ <- lexeme "ref:"
+      ref <- lexeme $ MP.takeWhileP (Just "ref") $ not . isSpace
+      symbolicRef <- lexeme $ MP.takeWhileP (Just "symbolic ref") $ not . isSpace
+      return (toGitRef symbolicRef, toGitRef ref)
+    parseCommit :: Parsec Void Text (GitRef, CommitId)
+    parseCommit = MP.label "commit and ref" $ do
+      commitId <- lexeme $ MP.takeWhileP (Just "commit id") $ not . isSpace
+      ref <- lexeme $ MP.takeWhileP (Just "ref") $ not . isSpace
+      return (toGitRef ref, commitId)
+
+data GitRef
+  = GitRef_Head
+  | GitRef_Branch Text
+  | GitRef_Tag Text
+  | GitRef_Other Text
+  deriving (Show, Eq, Ord)
+
+showGitRef :: GitRef -> Text
+showGitRef = \case
+  GitRef_Head -> "HEAD"
+  GitRef_Branch x -> "refs/heads/" <> x
+  GitRef_Tag x -> "refs/tags/" <> x
+  GitRef_Other x -> x
+
+toGitRef :: Text -> GitRef
+toGitRef = \case
+  "HEAD" -> GitRef_Head
+  r -> if
+    | Just s <- "refs/heads/" `T.stripPrefix` r -> GitRef_Branch s
+    | Just s <- "refs/tags/" `T.stripPrefix` r -> GitRef_Tag s
+    | otherwise -> GitRef_Other r
+
+type CommitId = Text
+
+type GitLsRemoteMaps = (Map GitRef GitRef, Map GitRef CommitId)
