diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2018, Monadic GmbH
+
+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 Monadic GmbH 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/README.rst b/README.rst
new file mode 100644
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,154 @@
+Haskell implementation of a `git remote helper`_ to store git repositories on
+IPFS_.
+
+Reverse-engineered from the `Go implementation`_, usable as both an executable
+and a library. Future development is going to focus on the latter.
+
+.. _git remote helper: https://git-scm.com/docs/git-remote-helpers
+.. _IPFS: https://ipfs.io
+.. _Go implementation: https://github.com/ipfs-shipyard/git-remote-ipld
+
+.. contents::
+   :local:
+   :backlinks: none
+
+Install
+================================================================================
+
+1. Build the ``git-remote-ipfs`` executable using either ``stack install`` or
+   ``cabal v2-install`` (requires ``cabal-install`` >= 2.4.0.0).
+
+2. Make sure the executable is on your ``$PATH``:
+   ``export PATH=$HOME/.local/bin:$PATH``, respectively
+   ``export PATH=$HOME/.cabal/bin:$PATH``
+
+3. Download and install the go-ipfs_ binary, and make sure the ipfs daemon is
+   running.
+
+.. _go-ipfs: https://docs.ipfs.io/introduction/install/
+
+Usage
+================================================================================
+
+To push a (branch of an) existing git repo to IPFS, using `.git/config` to
+keep track of pushes:
+
+.. code:: shell
+
+    $ # Add a new remote
+    $ git remote add ipfs ipfs://
+    $ # Push master
+    $ git push ipfs master
+    $ # Inspect the IPFS path
+    $ git remote get-url ipfs
+    ipfs://ipfs/Qm....
+
+Note that every push yields a new IPFS hash. The remote helper will rewrite the
+remote URL locally to keep track of the latest remote refs. To collaborate with
+other people (i.e. clone/pull from another machine), this URL needs to be
+communicated out-of-band. The output of `git remote get-url` can be used to `git
+clone`.
+
+An alternative is to use IPNS_, which provides a stable name the remote helper
+can update whenever the remote refs are updated:
+
+.. _IPNS: https://docs.ipfs.io/guides/concepts/ipns/
+
+.. code:: shell
+
+    $ repoid=$(ipfs key gen --type=ed25519 myrepo)
+    $ # Publish a pointer to the empty directory initially
+    $ ipfs name publish --key $repoid QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn
+    $ git remote add ipns ipfs://ipns/$repoid
+
+Note that resolving and updating an IPNS name is rather slow on the main IPFS
+network. Also note that only the owner of the IPNS name (that is, the keypair)
+can update it.
+
+Configuration
+--------------------------------------------------------------------------------
+
+A number of options regarding IPFS can be configured via git-config_:
+
+* ``ipfs.apiurl``
+
+  The URL of the IPFS daemon, default: ``http://localhost:5001``
+
+* ``ipfs.maxconnections``
+
+  The max number of connections to open to the IPFS daemon, default: ``30``
+
+* ``ipfs.maxblocksize``
+
+  The maximum block size (in bytes) supported by bitswap. Most users will
+  **not** want to change this. Default: ``2048000``
+
+The API URL can be overriden per-remote using the key
+``remote.<remote name>.ipfsapiurl``, e.g.:
+
+.. code:: shell
+
+    $ git config remote.origin.ipfsapiurl http://127.0.0.2:5001
+
+Additionally, if the environment variable ``IPFS_API_URL`` is set, it will be
+used instead of any git-config_ settings.
+
+.. _git-config: https://git-scm.com/docs/git-config
+
+How it works
+================================================================================
+
+IPFS blocks can be created with the ``git-raw`` CID format, which allows IPFS to
+interpret the data as loose git objects. When created with ``sha1`` as the
+(multihash) hash function, the block's CID corresponds to the SHA1 hash of the
+git object, i.e. one can be recovered from the other. Crucially, this allows the
+SHA1 references embedded in a loose git object (eg. parents and tree of a
+commit) to be traversed given a head reference.
+
+In order to obtain the head reference, IPLD links are created corresponding to
+the ``refs/heads`` directory hierarchy. Note that adding links to an IPFS object
+changes its hash - this means each push results in a new object (CID),
+which must be retained in order to clone or pull.
+
+Which git objects need to be pushed or fetched is determined via the `git remote
+helper`_ protocol, respectively by inspecting the local git repo and remote
+refs.
+
+Limitations
+--------------------------------------------------------------------------------
+
+* It is currently unclear how to keep track of the latest "anchor" object (the
+  one linking to the most recent heads). The obvious solution is to to use IPFS'
+  native name resolution mechanism (IPNS), yet IPNS names have a `very limited
+  lifetime <https://discuss.ipfs.io/t/ipns-max-lifetime/2130>`_ on the main IPFS
+  network.
+
+* IPFS blocks have a maximum size of 2MB. To work around this limitation,
+  objects exceeding this limit are created as regular IPFS objects, linked back
+  to the "anchor" object under the ``objects/`` hierarchy. When fetching, those
+  large objects are given precedence over blocks, so as to not stall forever
+  attempting to fetch blocks which the network does not replicate.
+
+* The approach to keep all git objects content-addressable in IPFS is nice
+  conceptually, but terribly inefficient: regular git resorts to packfiles_,
+  which use delta encoding and compression in order to obtain a more
+  space-efficient on-disk and wire format. There is, however, no global optimum
+  of how to pack any given git repo, and in fact git re-packs occasionally, as
+  it sees fit. It is thus unclear how to optimise git storage in a fully
+  distributed setting lacking online coordination.
+
+.. _packfiles: https://git-scm.com/book/en/v2/Git-Internals-Packfiles
+
+Testing
+================================================================================
+
+The project employs an end-to-end test suite, which is disabled by default as it
+requires a running IPFS daemon. The preferred way to run it is against a local
+IPFS network, as this speeds up IPNS resolution considerably.
+
+.. code:: shell
+
+   $ docker run --detach --rm --name=ipfs-test-network --publish 19301:5001 \
+      gcr.io/opensourcecoin/ipfs-test-network
+   $ IPFS_API_URL=http://127.0.0.1:19301 \
+      stack test --flag git-remote-ipfs:with-e2e-tests git-remote-ipfs
diff --git a/exe/git-remote-ipfs/Main.hs b/exe/git-remote-ipfs/Main.hs
new file mode 100644
--- /dev/null
+++ b/exe/git-remote-ipfs/Main.hs
@@ -0,0 +1,78 @@
+{-# LANGUAGE FlexibleContexts  #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main (main) where
+
+import           Control.Monad (when)
+import           Control.Monad.IO.Class (liftIO)
+import           Control.Monad.Reader (asks)
+import           Data.Attoparsec.ByteString (parseOnly)
+import qualified Data.ByteString.Char8 as C8
+import           Data.Conduit (runConduit, (.|))
+import           Data.Conduit.Binary (sinkHandle, sourceHandle)
+import qualified Data.Conduit.Combinators as Conduit
+import           Data.Foldable (for_)
+import           Data.IORef (readIORef)
+import           Data.Text (Text)
+import qualified Data.Text as Text (pack)
+import qualified Data.Text.IO as Text (hPutStrLn)
+import           Options.Applicative
+import           System.Exit (exitFailure, exitSuccess)
+import           System.IO
+                 ( BufferMode(LineBuffering)
+                 , hSetBuffering
+                 , stderr
+                 , stdin
+                 , stdout
+                 )
+
+import           Network.IPFS.Git.RemoteHelper
+import           Network.IPFS.Git.RemoteHelper.Command
+import           Network.IPFS.Git.RemoteHelper.Options
+import           Network.IPFS.Git.RemoteHelper.Trans
+
+data Error
+    = ParseError String
+    | ProcError  ProcessError
+
+instance DisplayError Error where
+    displayError = renderError
+
+renderError :: Error -> Text
+renderError = \case
+    ParseError e -> "Command failed to parse: " <> Text.pack e
+    ProcError  e -> renderProcessError e
+
+main :: IO ()
+main = do
+    for_ [stdin, stdout, stderr] $ flip hSetBuffering LineBuffering
+
+    opt <- execParser optInfo
+    env <- newEnv defaultLogger opt =<< getIpfsOptions opt
+    res <-
+        runRemoteHelper env . runConduit $
+               sourceHandle stdin
+            .| Conduit.linesUnboundedAscii
+            .| Conduit.filter (/= "") -- XXX: batching not supported yet
+            .| trace "> "
+            .| Conduit.mapM run
+            .| Conduit.map renderCommandResult
+            .| Conduit.encodeUtf8
+            .| trace "< "
+            .| sinkHandle  stdout
+
+    case res of
+        Left  e -> Text.hPutStrLn stderr (displayError e) *> exitFailure
+        Right _ -> exitSuccess
+  where
+    optInfo = info (helper <*> parseOptions) fullDesc
+
+    trace prefix = Conduit.mapM $ \x -> do
+        v <- liftIO . readIORef =<< asks envVerbosity
+        when (v > 1) $
+            liftIO (C8.hPutStr stderr prefix *> C8.hPutStrLn stderr x)
+        pure x
+
+    run bs = do
+        cmd <- either (throwRH . ParseError) pure $ parseOnly parseCommand bs
+        mapError ProcError $ processCommand cmd
diff --git a/git-remote-ipfs.cabal b/git-remote-ipfs.cabal
new file mode 100644
--- /dev/null
+++ b/git-remote-ipfs.cabal
@@ -0,0 +1,147 @@
+cabal-version: 2.2
+build-type:    Simple
+
+name:          git-remote-ipfs
+version:       0.1.0.0
+synopsis:      Git remote helper to store git objects on IPFS
+homepage:      https://github.com/oscoin/ipfs
+bug-reports:   https://github.com/oscoin/ipfs/issues
+license:       BSD-3-Clause
+license-file:  LICENSE
+author:        Kim Altintop
+maintainer:    Kim Altintop <kim@monadic.xyz>, Monadic Team <team@monadic.xyz>
+copyright:     2018 Monadic GmbH
+
+category:      Network
+
+extra-source-files:
+      CHANGELOG.md
+    , README.rst
+
+source-repository head
+    type: git
+    location: https://github.com/oscoin/ipfs
+
+flag with-e2e-tests
+    description: Build end-to-end test suite
+    default:     False
+    manual:      True
+
+common common
+    default-language: Haskell2010
+    ghc-options:
+        -Wall
+        -Wcompat
+        -Wincomplete-uni-patterns
+        -Wincomplete-record-updates
+        -Wredundant-constraints
+        -fprint-expanded-synonyms
+        -funbox-small-strict-fields
+
+    default-extensions:
+        BangPatterns
+        DeriveGeneric
+        LambdaCase
+        MultiWayIf
+        NamedFieldPuns
+        RecordWildCards
+        StrictData
+        TupleSections
+        TypeApplications
+
+library
+    import: common
+    hs-source-dirs: src
+
+    exposed-modules:
+        Network.IPFS.Git.RemoteHelper
+        Network.IPFS.Git.RemoteHelper.Client
+        Network.IPFS.Git.RemoteHelper.Command
+        Network.IPFS.Git.RemoteHelper.Format
+        Network.IPFS.Git.RemoteHelper.Generic
+        Network.IPFS.Git.RemoteHelper.Internal
+        Network.IPFS.Git.RemoteHelper.Options
+        Network.IPFS.Git.RemoteHelper.Trans
+
+    build-depends:
+        aeson
+      , async >= 2.1.1
+      , attoparsec
+      , base >= 4.9 && < 5
+      , bytestring
+      , conduit >= 1.3
+      , cryptonite
+      , filepath
+      , formatting
+      , generics-sop
+      , git >= 0.3.0
+      , http-client
+      , ipfs-api
+      , ipld-cid
+      , lens
+      , lens-aeson
+      , mtl
+      , multibase
+      , multihash-cryptonite
+      , network-uri
+      , optparse-applicative
+      , primitive
+      , safe-exceptions
+      , servant
+      , servant-client >= 0.15
+      , stm >= 2.5
+      , text
+      , transformers
+      , typed-process
+      , unordered-containers
+      , vector
+
+executable git-remote-ipfs
+    import: common
+    main-is: Main.hs
+    hs-source-dirs: exe/git-remote-ipfs
+
+    build-depends:
+        attoparsec
+      , base
+      , bytestring
+      , conduit
+      , conduit-extra
+      , git-remote-ipfs
+      , mtl
+      , optparse-applicative
+      , text
+
+    ghc-options: -threaded -rtsopts "-with-rtsopts=-maxN4 -A8m"
+
+test-suite e2e-tests
+    import: common
+
+    if flag(with-e2e-tests)
+        buildable: True
+    else
+        buildable: False
+
+    main-is: Main.hs
+    hs-source-dirs: test/e2e
+    type: exitcode-stdio-1.0
+
+    build-depends:
+        base
+      , bytestring
+      , entropy
+      , filepath
+      , git >= 0.3.0
+      , hourglass
+      , http-client
+      , ipfs-api
+      , lens
+      , lens-aeson
+      , safe-exceptions
+      , servant
+      , servant-client >= 0.15
+      , tasty
+      , tasty-hunit
+      , temporary
+      , text
+      , typed-process
diff --git a/src/Network/IPFS/Git/RemoteHelper.hs b/src/Network/IPFS/Git/RemoteHelper.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/IPFS/Git/RemoteHelper.hs
@@ -0,0 +1,375 @@
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators       #-}
+
+module Network.IPFS.Git.RemoteHelper
+    ( ProcessError
+    , renderProcessError
+
+    , processCommand
+    )
+where
+
+import           Control.Concurrent.MVar (modifyMVar)
+import           Control.Exception.Safe
+                 ( MonadCatch
+                 , SomeException
+                 , catchAny
+                 , tryAny
+                 )
+import           Control.Monad.Except
+import           Control.Monad.Reader
+import           Data.Bifunctor (first)
+import qualified Data.ByteString.Lazy as L
+import           Data.Foldable (for_, toList, traverse_)
+import           Data.Functor ((<&>))
+import           Data.HashMap.Strict (HashMap)
+import qualified Data.HashSet as Set
+import           Data.IORef
+import           Data.Maybe (catMaybes)
+import           Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.IO as Text (hPutStr, hPutStrLn)
+import qualified Data.Text.Read as Text
+import           Data.Traversable (for)
+import           Data.Vector (Vector)
+import qualified Data.Vector as Vector
+import           GHC.Stack (HasCallStack)
+import           System.FilePath (joinPath, splitDirectories)
+import           System.IO (hFlush, stderr)
+
+import           Data.Conduit
+import qualified Data.Conduit.Combinators as Conduit
+
+import           Data.IPLD.CID (CID, cidFromText, cidToText)
+
+import qualified Data.Git.Monad as Git (getGit, liftGit)
+import           Data.Git.Ref (SHA1)
+import qualified Data.Git.Ref as Git (Ref)
+import qualified Data.Git.Repository as Git (branchList, resolveRevision)
+import qualified Data.Git.Revision as Git (Revision(..))
+import qualified Data.Git.Storage as Git
+                 ( Git
+                 , findReference
+                 , getObject_
+                 , setObject
+                 )
+import qualified Data.Git.Storage.Loose as Git (looseMarshall, looseUnmarshall)
+import qualified Data.Git.Storage.Object as Git
+                 ( Object(ObjBlob)
+                 , ObjectLocation(..)
+                 , ObjectType(TypeBlob)
+                 , objectWrite
+                 , objectWriteHeader
+                 )
+
+import           Network.IPFS.Git.RemoteHelper.Client
+import           Network.IPFS.Git.RemoteHelper.Command
+import           Network.IPFS.Git.RemoteHelper.Format
+import           Network.IPFS.Git.RemoteHelper.Internal
+import           Network.IPFS.Git.RemoteHelper.Options (IpfsOptions'(..))
+import           Network.IPFS.Git.RemoteHelper.Trans
+
+
+data ProcessError
+    = GitError        SomeException
+    | IPFSError       ClientError
+    | CidError        String
+    | UnknownLocalRef Text
+    | HashError       HashMismatch
+    deriving Show
+
+-- | Indicate two hashes expected to be equal aren't.
+--
+-- The data constructors take the expected value first, then the actual.
+data HashMismatch
+    = CidMismatch CID CID
+    | RefMismatch (Git.Ref SHA1) (Git.Ref SHA1)
+    deriving Show
+
+instance DisplayError ProcessError where
+    displayError = renderProcessError
+
+renderProcessError :: ProcessError -> Text
+renderProcessError = \case
+    GitError  e       -> fmt ("Error accessing git repo: " % shown) e
+    IPFSError e       -> renderClientError e
+    CidError  e       -> fmt ("Cid conversion error: " % fstr) e
+    UnknownLocalRef r -> fmt ("Ref not found locally: " % ftxt) r
+    HashError  e      -> renderHashMismatch e
+
+renderHashMismatch :: HashMismatch -> Text
+renderHashMismatch (CidMismatch e a) =
+    fmt ("Cid mismatch: expected `" % fcid % "`, actual: `" % fcid % "`") e a
+renderHashMismatch (RefMismatch e a) =
+    fmt ("Ref mismatch: expected `" % fref % "`, actual: `" % fref % "`") e a
+
+processCommand
+    :: HasCallStack
+    => Command
+    -> RemoteHelper ProcessError CommandResult
+processCommand Capabilities =
+    pure $ CapabilitiesResult ["push", "fetch", "option"]
+
+processCommand (Option name value) = fmap OptionResult $
+    case name of
+        "verbosity" -> case Text.decimal value of
+            Left  e     -> pure $ OptionErr (fmt ("Invalid verbosity: " % fstr) e)
+            Right (n,_) -> do
+                ref <- asks envVerbosity
+                liftIO . atomicModifyIORef' ref $ const (n, ())
+                pure OptionOk
+
+        "dry-run" -> do
+            ref <- asks envDryRun
+            let update v = liftIO . atomicModifyIORef' ref $ const (v,())
+            case value of
+                "true"  -> OptionOk <$ update True
+                "false" -> OptionOk <$ update False
+                x       -> pure $ OptionErr (fmt ("Invalid value for dry-run: " % ftxt) x)
+
+        _ -> pure OptionUnsupported
+
+processCommand List = fmap ListResult $ do
+    root  <- asks envIpfsRoot
+    paths <- ipfs $ listPaths (cidToText root) 0
+
+    let refpath = joinPath . drop 1 . dropWhile (== "/") . splitDirectories . refPathPath
+    let name    = Text.pack . refpath
+
+    for paths $ \path ->
+        case refPathType path of
+            RefPathHead ->
+                case hexShaFromCidText (refPathHash path) of
+                    Left  e   -> throwRH $ CidError e
+                    Right sha -> pure $ ListRef (Just sha) (name path) []
+
+            RefPathRef -> do
+                dest <- ipfs $ getRef (refpath path)
+                pure $ ListRef (("@" <>) <$> dest) (name path) []
+
+processCommand ListForPush = fmap ListForPushResult $ do
+    root     <- asks envIpfsRoot
+    branches <-
+        map (fmt $ "refs/heads/" % frefName) . toList <$> git Git.branchList
+    logDebug $ fmt ("list for-push: branches: " % shown) branches
+
+    remoteRefs <- do
+        cids <-
+            forConcurrently branches $ \branch ->
+                ipfs (resolvePath (cidToText root <> "/" <> branch))
+
+        for (catMaybes cids) $
+            liftEitherRH . first CidError . hexShaFromCidText
+
+    logDebug $ "list for-push: remoteRefs: " <> Text.pack (show remoteRefs)
+
+    pure . map (\(ref, branch) -> ListRef (Just ref) branch [])
+         . flip zip branches
+         $ case remoteRefs of
+               [] -> repeat "0000000000000000000000000000000000000000"
+               xs -> xs
+
+processCommand (Push force localRef remoteRef) =
+    let
+        err = PushResult . PushErr remoteRef
+        ok  = PushResult . PushOk  remoteRef
+     in
+        fmap ok (processPush force localRef remoteRef)
+            --`catchRH`  (pure . err . renderProcessError)
+            `catchAny` (pure . err . Text.pack . show)
+
+processCommand (Fetch sha _) = FetchOk <$ processFetch sha
+
+--------------------------------------------------------------------------------
+
+processPush
+    :: HasCallStack
+    => Bool
+    -> Text
+    -> Text
+    -> RemoteHelper ProcessError CID
+processPush _ localRef remoteRef = do
+    root <- asks envIpfsRoot
+
+    localRefCid <- do
+        ref <- git $ flip Git.resolveRevision (Git.Revision (Text.unpack localRef) [])
+        maybe (throwRH $ UnknownLocalRef localRef) (pure . refToCid) ref
+
+    remoteRefCid <- do
+        refCid <- ipfs $ resolvePath (cidToText root <> "/" <> remoteRef)
+        pure $ refCid >>= hush . cidFromText
+
+    maxConc <- asks $ ipfsMaxConns . envIpfsOptions
+    runConduit $
+           yield localRefCid
+        .| collectObjects remoteRefCid
+        .| progress ("Pushed " % fint % " objects")
+        .| Conduit.conduitVector maxConc
+        .| Conduit.mapM_ (\(batch :: Vector (CID, Git.Object SHA1)) ->
+               forConcurrently_ batch $ pushObject root)
+
+    ipfs $ do
+        -- Update the root to point to the local ref, up to which we just pushed
+        root' <- patchLink root remoteRef localRefCid
+        -- The remote HEAD denotes the default branch to check out. If it is not
+        -- present, git clone will refuse to check out the worktree and exit
+        -- with a scary error message.
+        linkedObject "refs/heads/master" root' "HEAD" >>= \hEAD ->
+            -- HEAD is our new root, update the remote.url and pin
+            hEAD <$ concurrently_ (updateRemoteUrl hEAD) (pin hEAD)
+  where
+    collectObjects
+        :: Maybe CID -- the CID already present remotely, if any
+        -> ConduitT CID (CID, Git.Object SHA1) (RemoteHelper ProcessError) ()
+    collectObjects remoteRefCid = do
+        sref <- liftIO . newIORef $ maybe mempty Set.singleton remoteRefCid
+        awaitForever $ \cid -> do
+            seen <- liftIO $ readIORef sref
+            unless (Set.member cid seen) $ do
+                liftIO $ modifyIORef' sref (Set.insert cid)
+                obj <- lift $ do
+                    sha <- liftEitherRH . first CidError $ cidToRef @SHA1 cid
+                    git $ \repo -> Git.getObject_ repo sha True
+
+                yield (cid, obj)
+                traverse_ leftover $ objectLinks obj
+
+    pushObject root (cid, obj) = do
+        let raw = Git.looseMarshall obj
+
+        logDebug $ fmt ("Pushing " % fcid) cid
+        blkCid <- ipfs $ putBlock raw
+        when (cid /= blkCid) $
+            throwRH $ HashError (CidMismatch cid blkCid)
+
+        -- If the object exceeds the maximum block size, bitswap won't replicate
+        -- the block. To work around this, we create a regular object and link
+        -- it to the root object as @objects/<block CID>@.
+        --
+        -- As suggested by
+        -- <https://github.com/ipfs-shipyard/git-remote-ipld/issues/12>, objects
+        -- can potentially be deduplicated by storing the data separate from the
+        -- header. This only makes sense for git blobs, so we don't bother for
+        -- other object types.
+        maxBlockSize <- asks $ fromIntegral . ipfsMaxBlockSize . envIpfsOptions
+        when (L.length raw > maxBlockSize) $ do
+            let linkName = "objects/" <> cidToText blkCid
+            void . ipfs $
+                case obj of
+                    Git.ObjBlob blob -> pushLargeBlob blob root linkName
+                    _                -> linkedObject  raw  root linkName
+
+    pushLargeBlob blob root linkName =
+        let
+            hdr = L.fromStrict $ Git.objectWriteHeader Git.TypeBlob len
+            len = fromIntegral $ L.length dat
+            dat = Git.objectWrite (Git.ObjBlob blob)
+         in do
+            hdrCid <- addObject hdr
+            datCid <- addObject dat
+            patchLink hdrCid "0" datCid >>= patchLink root linkName
+
+    linkedObject bytes root linkName =
+        addObject bytes >>= patchLink root linkName
+
+processFetch :: HasCallStack => Text -> RemoteHelper ProcessError ()
+processFetch sha = do
+    cid  <- liftEitherRH . first CidError $ cidFromHexShaText sha
+    lobs <- loadLobs
+    maxC <- asks $ ipfsMaxConns . envIpfsOptions
+
+    runConduit $
+           yield (Vector.singleton cid)
+        .| fetchObjects lobs maxC
+        .| progress ("Fetched " % fint % " objects")
+        .| Conduit.mapM_ storeObject
+
+    void $ asks envIpfsRoot >>= ipfs . pin
+  where
+    fetchObjects
+        :: HashMap CID CID -- LOB index
+        -> Int             -- Max concurrency
+        -> ConduitT (Vector CID)
+                    (Git.Ref SHA1, Git.Object SHA1)
+                    (RemoteHelper ProcessError)
+                    ()
+    fetchObjects !lobs maxConc = do
+        sref <- liftIO $ newIORef Set.empty
+        awaitForever $ \cids -> do
+            seen <- liftIO $ readIORef sref
+            todo <-
+                fmap (Vector.mapMaybe id) . for cids $ \cid ->
+                    if Set.member cid seen then
+                        pure Nothing
+                    else do
+                        liftIO $ modifyIORef' sref (Set.insert cid)
+                        lift $ do
+                            ref <- liftEitherRH . first CidError $ cidToRef cid
+                            lookupObject ref <&> \case
+                                Git.NotFound -> Just (ref, cid)
+                                _            -> Nothing
+
+            for_ (chunksOfV maxConc todo) $ \batch -> do
+                objs <-
+                    lift . forConcurrently batch $ \(ref, cid) ->
+                        (ref,) <$> fetchObject lobs cid
+                Conduit.yieldMany objs
+                traverse_ leftover $ Vector.map (objectLinks . snd) objs
+
+    fetchObject lobs cid = ipfs $ do
+        lob <- provideLargeObject lobs cid
+        Git.looseUnmarshall @SHA1 <$> maybe (getBlock cid) pure lob
+
+    storeObject (ref, obj) = do
+        ref' <- git $ flip Git.setObject obj
+        when (ref' /= ref) $
+            throwRH $ HashError (RefMismatch ref ref')
+
+    lookupObject ref = git $ flip Git.findReference ref
+
+    loadLobs = do
+        env <- ask
+        (>>= either throwError pure)
+            . liftIO . modifyMVar (envLobs env) $ \case
+                Just ls -> pure (Just ls, Right ls)
+                Nothing ->
+                    runRemoteHelper env (ipfs (largeObjects (envIpfsRoot env))) >>= \case
+                        Left  e  -> pure (Nothing, Left  e)
+                        Right ls -> pure (Just ls, Right ls)
+
+--------------------------------------------------------------------------------
+
+ipfs :: Monad m
+     => RemoteHelperT ClientError m a
+     -> RemoteHelperT ProcessError m a
+ipfs = mapError IPFSError
+
+-- XXX: hs-git uses 'error' deliberately, should be using 'tryAnyDeep' here.
+-- Requires patch to upstream to get 'NFData' instances everywhere.
+git :: (MonadCatch m, MonadIO m, HasCallStack)
+    => (Git.Git SHA1 -> IO a)
+    -> RemoteHelperT ProcessError m a
+git f = do
+    repo <- Git.getGit
+    res  <- Git.liftGit $ first GitError <$> tryAny (f repo)
+    either throwRH pure res
+
+chunksOfV :: Int -> Vector a -> Vector (Vector a)
+chunksOfV n = Vector.unfoldr go
+  where
+    go v | Vector.null v = Nothing
+         | otherwise     = Just $ Vector.splitAt n v
+
+progress :: MonadIO m => Format Text (Int -> Text) -> ConduitT a a m ()
+progress msg = do
+    let msg' = "\r" % msg % "\r"
+    cref <- liftIO $ newIORef (0 :: Int)
+    awaitForever $ \x -> do
+        liftIO $ do
+            count <- readIORef cref
+            Text.hPutStr stderr (fmt msg' count) *> hFlush stderr
+            modifyIORef' cref (+1)
+        yield x
+    liftIO $ Text.hPutStrLn stderr mempty *> hFlush stderr
diff --git a/src/Network/IPFS/Git/RemoteHelper/Client.hs b/src/Network/IPFS/Git/RemoteHelper/Client.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/IPFS/Git/RemoteHelper/Client.hs
@@ -0,0 +1,401 @@
+{-# LANGUAGE CPP                       #-}
+{-# LANGUAGE ExistentialQuantification #-}
+{-# LANGUAGE FlexibleContexts          #-}
+{-# LANGUAGE NoMonomorphismRestriction #-}
+{-# LANGUAGE OverloadedStrings         #-}
+{-# LANGUAGE TypeFamilies              #-}
+{-# LANGUAGE TypeOperators             #-}
+
+{-# OPTIONS_GHC -fno-warn-missing-signatures #-}
+
+module Network.IPFS.Git.RemoteHelper.Client
+    ( RefPath (..)
+    , RefPathType (..)
+
+    , ClientError
+    , renderClientError
+
+    , listPaths
+    , getRef
+    , resolvePath
+    , patchLink
+    , putBlock
+    , addObject
+    , pin
+    , largeObjects
+    , provideLargeObject
+    , getBlock
+    , updateRemoteUrl
+    )
+where
+
+import           Control.Applicative (liftA2)
+import           Control.Exception.Safe
+import qualified Control.Lens as Lens
+import           Control.Monad.Except
+import           Control.Monad.Reader
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Lens as Lens
+import           Data.Bifunctor (first)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as L (ByteString, fromChunks, null)
+import           Data.HashMap.Strict (HashMap)
+import qualified Data.HashMap.Strict as Map
+import           Data.Maybe (fromMaybe, maybeToList)
+import           Data.Proxy (Proxy(..))
+import           Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Lazy as LT
+import qualified Data.Text.Lazy.Encoding as LT (decodeUtf8)
+import           Data.Traversable (for)
+import           System.FilePath (joinPath)
+import           System.Process.Typed (runProcess_, shell)
+
+import           Servant.API
+import qualified Servant.Client as Servant
+import qualified Servant.Client.Streaming as ServantS
+import           Servant.Types.SourceT
+
+import           Data.IPLD.CID (CID, cidFromText, cidToText)
+import           Network.IPFS.API
+
+import           Network.IPFS.Git.RemoteHelper.Format
+import           Network.IPFS.Git.RemoteHelper.Internal
+import           Network.IPFS.Git.RemoteHelper.Options
+                 ( IpfsPath(..)
+                 , optRemoteName
+                 , optRemoteUrl
+                 , remoteUrlIpfsPath
+                 , remoteUrlScheme
+                 )
+import           Network.IPFS.Git.RemoteHelper.Trans
+
+#if MIN_VERSION_servant_client(0,16,0)
+type ServantError = Servant.ClientError
+#else
+type ServantError = Servant.ServantError
+#endif
+
+data ClientError
+    = ApiError ServantError
+    | InvalidResponse Text Aeson.Value
+    | CidError String
+    | StreamingError String
+    deriving Show
+
+instance DisplayError ClientError where
+    displayError = renderClientError
+
+renderClientError :: ClientError -> Text
+renderClientError = \case
+    ApiError        e       -> fmt ("Error talking to IPFS: " % shown) e
+    InvalidResponse msg raw -> fmt (ftxt % " -- " % shown) msg raw
+    CidError        msg     -> fmt ("Cid conversion error: " % fstr) msg
+    StreamingError  msg     -> fmt ("SourceT yielded error: " % fstr) msg
+
+data RefPath = RefPath
+    { refPathPath :: FilePath
+    , refPathType :: RefPathType
+    , refPathHash :: Text
+    }
+
+data RefPathType = RefPathRef | RefPathHead
+
+listPaths
+    :: MonadIO m
+    => Text
+    -> Word
+    -> RemoteHelperT ClientError m [RefPath]
+listPaths path !level = do
+    logDebug $ "listPaths: " <> path
+    refs <- ipfsList path Nothing (Just True)
+    logDebug $ "listPaths: " <> Text.pack (show refs)
+    fmap concat <$> for (Lens.toListOf linksL refs) $ \link ->
+            case Lens.firstOf typeL link of
+                Nothing -> throwRH $
+                    InvalidResponse "ipfsList: missing link type" refs
+                -- directory:
+                Just  1 ->
+                    case Lens.firstOf nameL link of
+                        Just "objects" | level == 0 -> pure []
+                        Just name      -> listPaths (path <> "/" <> name) (level + 1)
+                        Nothing        -> pure [] -- ??
+                -- file:
+                Just  2 -> pure . maybeToList $ do
+                    name <- Lens.firstOf nameL link
+                    hash <- Lens.firstOf hashL link
+                    pure RefPath
+                        { refPathPath = Text.unpack $ path <> "/" <> name
+                        , refPathType = RefPathRef
+                        , refPathHash = hash
+                        }
+                -- unknown (assume head):
+                --
+                -- Nb.: unknown is 0 in ipfs version >= 0.4.19, and -1 in
+                -- earlier versions.
+                Just x | x == 0 || x == -1 -> pure . maybeToList $ do
+                    name <- Lens.firstOf nameL link
+                    hash <- Lens.firstOf hashL link
+                    pure RefPath
+                        { refPathPath = Text.unpack $ path <> "/" <> name
+                        , refPathType = RefPathHead
+                        , refPathHash = hash
+                        }
+                -- Post-0.4.19, this can only be @TSymlink@, previously .. idk.
+                -- Either way, we don't know what to do.
+                Just x -> throwRH $
+                    InvalidResponse (fmt ("Unexpected link type: " % shown) x) refs
+
+getRef
+    :: (MonadCatch m, MonadIO m)
+    => FilePath
+    -> RemoteHelperT ClientError m (Maybe Text)
+getRef name = do
+    root <- asks $ Text.unpack . cidToText . envIpfsRoot
+
+    let path = Text.pack $ joinPath [root, name]
+    logDebug $ "getRef: " <> path
+    bs <- stream (ipfsCat path Nothing Nothing) `catchRH` noLink
+    if | L.null bs -> pure Nothing
+       | otherwise -> pure . Just . LT.toStrict $ LT.decodeUtf8 bs
+  where
+    noLink (ApiError e) | isNoLink e = pure mempty
+    noLink e            = throwRH e
+
+resolvePath :: MonadIO m => Text -> RemoteHelperT ClientError m (Maybe Text)
+resolvePath p = do
+    logDebug $ "Resolve path: " <> p
+    res <- ipfsResolve p Nothing Nothing Nothing `catchRH` noLink
+    pure . fmap strip $ Lens.firstOf pathL res
+  where
+    noLink (ApiError e) | isNoLink e = pure Aeson.Null
+    noLink e            = throwRH e
+
+    strip x = fromMaybe x $ Text.stripPrefix "/ipfs/" x
+
+patchLink :: MonadIO m => CID -> Text -> CID -> RemoteHelperT ClientError m CID
+patchLink from name to = do
+    logDebug $ fmt ("patchLink " % fcid % " " % ftxt % " " % fcid) from name to
+    res <- ipfsObjectPatchAddLink (cidToText from) name (cidToText to) (Just True)
+    either throwRH pure $ do
+        hash <- note (invalidResponse res) $ Lens.firstOf hashL res
+        first CidError $ cidFromText hash
+  where
+    invalidResponse =
+        InvalidResponse "ipfsObjectPatchAddLink: expected 'Hash' key"
+
+largeObjects :: MonadIO m => CID -> RemoteHelperT ClientError m (HashMap CID CID)
+largeObjects root = do
+    res <-
+        ipfsList (cidToText root <> "/objects") Nothing Nothing `catchRH` noLink
+    either throwRH (pure . Map.fromList)
+        . for (Lens.toListOf linksL res) $ \link -> do
+            hash <- toCid =<<
+                note (invalidResponse "Hash" res) (Lens.firstOf hashL link)
+            name <- toCid =<<
+                note (invalidResponse "Link" res) (Lens.firstOf nameL link)
+            pure (name, hash)
+  where
+    noLink (ApiError e) | isNoLink e = pure Aeson.Null
+    noLink e            = throwRH e
+
+    invalidResponse =
+        InvalidResponse . fmt ("ipfsList: expected '" % ftxt % "' key")
+
+    toCid = first CidError . cidFromText
+
+provideLargeObject
+    :: (MonadCatch m, MonadIO m)
+    => HashMap CID CID
+    -> CID
+    -> RemoteHelperT ClientError m (Maybe L.ByteString)
+provideLargeObject largeObjs cid =
+    for (Map.lookup cid largeObjs) $ \cid' ->
+        stream $ ipfsCat ("/ipfs/" <> cidToText cid') Nothing Nothing
+
+putBlock :: MonadIO m => L.ByteString -> RemoteHelperT ClientError m CID
+putBlock bs = do
+    res <- ipfsBlockPut bs (Just "git-raw") (Just "sha1") Nothing
+    either throwRH pure $ do
+        key <- note (invalidResponse res) $ Lens.firstOf keyL res
+        first CidError $ cidFromText key
+  where
+    invalidResponse = InvalidResponse "ipfsBlockPut: expected 'Key' key"
+
+addObject :: MonadIO m => L.ByteString -> RemoteHelperT ClientError m CID
+addObject bs = do
+    res <- ipfsAdd' bs
+    liftEitherRH $ do
+        sha <- note (invalidResponse res) $ Lens.firstOf hashL res
+        first CidError $ cidFromText sha
+  where
+    invalidResponse = InvalidResponse "ipfsAdd: expected 'Hash' key"
+
+    ipfsAdd' bs' =
+        ipfsAdd bs'         -- data
+                Nothing     -- recursive
+                Nothing     -- quiet
+                Nothing     -- quieter
+                Nothing     -- silent
+                Nothing     -- progress
+                Nothing     -- trickle
+                Nothing     -- only-hash
+                Nothing     -- wrap-with-directory
+                Nothing     -- hidden
+                Nothing     -- chunker
+                (Just True) -- pin
+                Nothing     -- raw-leaves
+                Nothing     -- nocopy
+                Nothing     -- fscache
+                Nothing     -- cid-version
+                Nothing     -- hash function
+
+pin :: MonadIO m => CID -> RemoteHelperT ClientError m [CID]
+pin cid = do
+    res <- ipfsPinAdd (cidToText cid)
+                      (Just True)     -- recursive
+                      (Just False)    -- progress
+    liftEitherRH $
+        traverse (first CidError . cidFromText) $ Lens.toListOf pinsL res
+
+getBlock
+    :: (MonadCatch m, MonadIO m)
+    => CID
+    -> RemoteHelperT ClientError m L.ByteString
+getBlock cid = stream $ ipfsBlockGet (cidToText cid)
+
+updateRemoteUrl :: MonadIO m => CID -> RemoteHelperT ClientError m ()
+updateRemoteUrl root = do
+    url <- asks $ optRemoteUrl . envOptions
+    case remoteUrlIpfsPath url of
+        IpfsPathIpns name -> viaIpns name
+        IpfsPathIpfs _    -> viaConfig (remoteUrlScheme url) root
+  where
+    viaIpns name = do
+        let ipnsTarget = "/ipfs/" <> cidToText root
+        logInfo $
+            fmt ("Updating IPNS link " % ftxt % " to " % ftxt) name ipnsTarget
+        res <-
+            ipfsNamePublish ipnsTarget
+                            (Just True)       -- resolve
+                            (Just "2540400h") -- lifetime
+                            Nothing           -- ttl (caching)
+                            (Just name)       -- key
+
+        case liftA2 (\name' root' -> name' == name && root' == ipnsTarget)
+                    (Lens.firstOf nameL  res)
+                    (Lens.firstOf valueL res) of
+            Just True -> pure ()
+            _         -> throwRH $
+                InvalidResponse
+                    (fmt ( "ipfsNamePublish: expected name "
+                         % "`" % ftxt % "` "
+                         % "pointing to `" % ftxt % "`"
+                         ) name ipnsTarget)
+                    res
+
+    viaConfig scheme cid = do
+        remoteName <- asks $ Text.pack . optRemoteName . envOptions
+        let
+            configKey = "remote." <> remoteName <> ".url"
+            remoteUrl = scheme <> "://ipfs/" <> cidToText cid
+         in do
+            logInfo $
+                fmt ("Updating " % ftxt % " to " % ftxt) configKey remoteUrl
+            runProcess_ . shell . Text.unpack $
+                "git config " <> configKey <> " " <> remoteUrl
+
+-- lenses
+
+linksL :: Lens.AsValue t => Lens.Traversal' t Aeson.Value
+linksL = Lens.key "Objects" . Lens.nth 0 . Lens.key "Links" . Lens.values
+
+typeL :: Lens.AsValue t => Lens.Traversal' t Int
+typeL = Lens.key "Type" . Lens._Integral
+
+nameL :: Lens.AsValue t => Lens.Traversal' t Text
+nameL = Lens.key "Name" . Lens._String
+
+messageL :: Lens.AsValue t => Lens.Traversal' t Text
+messageL = Lens.key "Message" . Lens._String
+
+hashL :: Lens.AsValue t => Lens.Traversal' t Text
+hashL = Lens.key "Hash" . Lens._String
+
+pathL :: Lens.AsValue t => Lens.Traversal' t Text
+pathL = Lens.key "Path" . Lens._String
+
+keyL :: Lens.AsValue t => Lens.Traversal' t Text
+keyL = Lens.key "Key" . Lens._String
+
+valueL :: Lens.AsValue t => Lens.Traversal' t Text
+valueL = Lens.key "Value" . Lens._String
+
+pinsL :: Lens.AsValue t => Lens.IndexedTraversal' Int t Text
+pinsL = Lens.key "Pins" . Lens.values . Lens._String
+
+-- brilliant API design
+isNoLink :: ServantError -> Bool
+isNoLink = \case
+#if MIN_VERSION_servant_client(0,16,0)
+    Servant.FailureResponse _ res ->
+#else
+    Servant.FailureResponse   res ->
+#endif
+        case Lens.firstOf messageL (Servant.responseBody res) of
+            Just  m | "no link named" `Text.isPrefixOf` m -> True
+            _                                             -> False
+    _ -> False
+
+-- | Subset of IPFS API needed for remote helper
+type IPFS =
+         ApiV0Add
+    :<|> ApiV0BlockPut
+    :<|> ApiV0Ls
+    :<|> ApiV0ObjectPatchAddLink
+    :<|> ApiV0Resolve
+    :<|> ApiV0NamePublish
+    :<|> ApiV0PinAdd
+
+ipfsAdd
+    :<|> ipfsBlockPut
+    :<|> ipfsList
+    :<|> ipfsObjectPatchAddLink
+    :<|> ipfsResolve
+    :<|> ipfsNamePublish
+    :<|> ipfsPinAdd
+    = client
+  where
+    client = Servant.hoistClient api nat (Servant.client api)
+
+    nat m = do
+        env <- asks envClient
+        either (throwRH . ApiError) pure =<< liftIO (Servant.runClientM m env)
+
+    api = Proxy @IPFS
+
+-- | Streaming endpoints
+type IPFS' = ApiV0BlockGet :<|> ApiV0Cat
+
+ipfsBlockGet :<|> ipfsCat = ServantS.client (Proxy @IPFS')
+
+stream
+    :: (MonadCatch m, MonadIO m)
+    => ServantS.ClientM (SourceT IO BS.ByteString)
+    -> RemoteHelperT ClientError m L.ByteString
+stream m = do
+    env <- asks envClient
+    liftIO (go env) `catches` handlers
+  where
+    handlers =
+        [ Handler $ throwRH . ApiError
+        , Handler $ \(StringException e _) -> throwRH $ StreamingError e
+        ]
+
+    go env =
+        ServantS.withClientM m env $ \case
+            Left  e -> throwM e
+            Right s -> runExceptT (runSourceT s) >>= \case
+                Left  e'  -> throwString e'
+                Right bss -> pure $ L.fromChunks bss
+
diff --git a/src/Network/IPFS/Git/RemoteHelper/Command.hs b/src/Network/IPFS/Git/RemoteHelper/Command.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/IPFS/Git/RemoteHelper/Command.hs
@@ -0,0 +1,112 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Network.IPFS.Git.RemoteHelper.Command
+    ( Command (..)
+    , CommandResult (..)
+    , OptRes (..)
+    , ListRef (..)
+    , PushRes (..)
+
+    , parseCommand
+    , renderCommandResult
+    )
+where
+
+import           Control.Applicative (liftA2, optional, (<|>))
+import           Data.Attoparsec.ByteString.Char8 ((<?>))
+import qualified Data.Attoparsec.ByteString.Char8 as P
+import           Data.Char (isSpace)
+import           Data.Maybe (fromMaybe, isJust)
+import           Data.Text (Text)
+import qualified Data.Text as Text
+import           Data.Text.Encoding (decodeUtf8)
+
+import           Data.IPLD.CID (CID)
+
+data Command
+    = Capabilities
+    | List
+    | ListForPush
+    | Option Text Text      -- name, value
+    | Fetch Text Text       -- sha1, name
+    | Push Bool Text Text   -- force, src ref, dest ref
+    deriving Show
+
+data CommandResult
+    = CapabilitiesResult [Text]
+    | ListResult         [ListRef]
+    | ListForPushResult  [ListRef]
+    | OptionResult       OptRes
+    | FetchOk
+    | PushResult         PushRes
+
+data OptRes
+    = OptionOk
+    | OptionUnsupported
+    | OptionErr Text
+
+data ListRef = ListRef
+    { listRefValue :: Maybe Text
+    , listRefName  :: Text
+    , listRefAttrs :: [Text]
+    }
+
+data PushRes = PushOk Text CID | PushErr Text Text
+
+parseCommand :: P.Parser Command
+parseCommand =
+        (capabilities <?> "capabilities")
+    <|> (listForPush  <?> "listForPush" )
+    <|> (list         <?> "list"        )
+    <|> (option       <?> "option"      )
+    <|> (fetch        <?> "fetch"       )
+    <|> (push         <?> "push"        )
+  where
+    capabilities = Capabilities <$ P.string "capabilities" <* eof
+    list         = List <$ P.string "list" <* eof
+    listForPush  = ListForPush <$ P.string "list for-push" <* eof
+
+    option =
+           P.string "option" *> P.skipSpace
+        *> liftA2 Option (notSpace <* P.skipSpace) notSpace
+        <* eof
+
+    fetch =
+           P.string "fetch" *> P.skipSpace
+        *> liftA2 Fetch (notSpace <* P.skipSpace) notSpace
+        <* eof
+
+    push = do
+        P.string "push" *> P.skipSpace
+        force <- isJust <$> optional (P.char '+')
+        src   <- decodeUtf8 <$> P.takeWhile1 (/= ':')  <* P.char ':'
+        dst   <- decodeUtf8 <$> P.takeByteString
+        pure $ Push force src dst
+
+    notSpace = decodeUtf8 <$> P.takeWhile1 (not . isSpace)
+    eof      = P.endOfInput
+
+renderCommandResult :: CommandResult -> Text
+renderCommandResult = \case
+    CapabilitiesResult xs -> Text.unlines xs <> "\n"
+    ListResult         xs -> Text.unlines (map renderListRef xs) <> "\n"
+    ListForPushResult  xs -> Text.unlines (map renderListRef xs) <> "\n"
+    OptionResult       x  -> renderOptRes x <> "\n"
+    FetchOk               -> "\n"
+    PushResult         x  -> renderPushRes x <> "\n\n"
+
+renderListRef :: ListRef -> Text
+renderListRef ListRef{..} =
+       fromMaybe "?" listRefValue
+    <> " "
+    <> listRefName
+    <> Text.intercalate " " listRefAttrs
+
+renderOptRes :: OptRes -> Text
+renderOptRes OptionOk          = "ok"
+renderOptRes OptionUnsupported = "unsupported"
+renderOptRes (OptionErr descr) = "error " <> descr
+
+renderPushRes :: PushRes -> Text
+renderPushRes (PushOk  dst _    ) = "ok " <> dst
+renderPushRes (PushErr ref descr) = "error " <> ref <> " " <> descr
diff --git a/src/Network/IPFS/Git/RemoteHelper/Format.hs b/src/Network/IPFS/Git/RemoteHelper/Format.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/IPFS/Git/RemoteHelper/Format.hs
@@ -0,0 +1,47 @@
+module Network.IPFS.Git.RemoteHelper.Format
+    ( fmt
+    , sfmt
+    , fstr
+    , ftxt
+    , fint
+    , fcid
+    , fref
+    , frefName
+
+    -- Re-exports
+    , (%)
+    , shown
+    , Format
+    )
+where
+
+import           Data.Text (Text)
+import           Formatting
+
+import qualified Data.Git.Named as Git (RefName, refNameRaw)
+import qualified Data.Git.Ref as Git (Ref, toHexString)
+import           Data.IPLD.CID (CID, cidToText)
+
+fmt :: Format Text a -> a
+fmt = sformat
+
+sfmt :: Format String a -> a
+sfmt = formatToString
+
+fstr :: Format r (String -> r)
+fstr = string
+
+ftxt :: Format r (Text -> r)
+ftxt = stext
+
+fint :: Integral a => Format r (a -> r)
+fint = int
+
+fcid :: Format r (CID -> r)
+fcid = mapf cidToText stext
+
+fref :: Format r (Git.Ref hash -> r)
+fref = mapf Git.toHexString string
+
+frefName :: Format r (Git.RefName -> r)
+frefName = mapf Git.refNameRaw string
diff --git a/src/Network/IPFS/Git/RemoteHelper/Generic.hs b/src/Network/IPFS/Git/RemoteHelper/Generic.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/IPFS/Git/RemoteHelper/Generic.hs
@@ -0,0 +1,81 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE KindSignatures   #-}
+{-# LANGUAGE MonoLocalBinds   #-}
+{-# LANGUAGE RankNTypes       #-}
+{-# LANGUAGE TypeFamilies     #-}
+
+module Network.IPFS.Git.RemoteHelper.Generic
+    ( HKD
+    , gvalidate
+    , ginvalidate
+    )
+where
+
+import           Generics.SOP
+
+-- The venerable defunctionalisation, applied generically, with a hipster touch
+-- as popularised by Sandy "type fam" Maguire ("Higher Kinded Data", HKD)
+
+-- | Eliminates the boring identity functor 'I'
+type family HKD (f :: * -> *) (a :: *) :: * where
+    HKD I a = a
+    HKD f a = f a
+
+-- | Generically lift the applicative functor 'f' out of a structure.
+--
+-- Example:
+--
+-- Given a datatype parametrised over the functor 'Maybe', return 'Just' the
+-- value if all 'Maybe' fields are 'Just', thereby changing the functor to 'I'.
+-- 'Nothing' otherwise.
+--
+-- >>> :{
+-- >>> data Person f = Person { name :: f String, age :: f Int }
+-- >>>     deriving (Show, Generic, GHC.Generic)
+-- >>> :}
+-- >>> gvalidate $ Person (Just "LeBoeuf") (Just 42)
+-- Just (Person (I "LeBoeuf") (I 42))
+-- >>> gvalidate $ Person Nothing (Just 69)
+-- Nothing
+--
+gvalidate
+    :: ( Generic (a f)
+       , Generic (a I)
+       , Applicative f
+       , AllZip2 (LiftedCoercible I f) (Code (a f)) (Code (a I))
+       )
+    => a f
+    -> f (a I)
+gvalidate = (to <$>) . hsequence . hcoerce . from
+
+-- | Generically change the functor of a structure from 'I' to 'f', given a
+-- constructor of 'f'.
+--
+-- Morally the inverse of 'gvalidate', although the specialisation to the
+-- identity functor makes this trivial.
+--
+-- If 'a f' is a 'Semigroup', this can be used to apply defaults to a partial
+-- value.
+--
+-- >>> :{
+-- >>> instance Semigroup (Person Last) where
+-- >>>     a <> b = Person { name = on (<>) name a b, age = on (<>) age a b }
+-- >>> -- For convenience, also define 'Monoid'
+-- >>> instance Monoid (Person Last) were
+-- >>>     mempty  = Person mempty mempty
+-- >>>     mappend = (<>)
+-- >>> :}
+-- >>> defaultPerson :: Person I
+-- >>> defaultPerson = Person "Sandy" 28
+-- >>> gvalidate $ ginvalidate pure defaultPerson <> Person (pure "Maguire") mempty
+-- Just (Person (I "Maguire") (I 28)
+--
+ginvalidate
+    :: ( Generic (a f)
+       , Generic (a I)
+       , AllZip2 (LiftedCoercible f I) (Code (a I)) (Code (a f))
+       )
+    => (forall b. b -> f b)
+    -> a I
+    -> a f
+ginvalidate f = to . hcoerce . hmap (f . unI) . from
diff --git a/src/Network/IPFS/Git/RemoteHelper/Internal.hs b/src/Network/IPFS/Git/RemoteHelper/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/IPFS/Git/RemoteHelper/Internal.hs
@@ -0,0 +1,93 @@
+{-# LANGUAGE ExistentialQuantification #-}
+{-# LANGUAGE ScopedTypeVariables       #-}
+
+module Network.IPFS.Git.RemoteHelper.Internal where
+
+import qualified Crypto.Hash as C
+import qualified Data.ByteString.BaseN as BaseN
+import           Data.Maybe (fromMaybe)
+import           Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import           Data.Vector (Vector)
+import qualified Data.Vector as Vector
+
+import qualified Data.Git.Ref as Git
+import           Data.Git.Storage.Object (Object(..))
+import           Data.Git.Types
+
+import           Data.IPLD.CID
+                 ( CID
+                 , Codec(..)
+                 , cidCodec
+                 , cidFromText
+                 , cidHash
+                 , newCidV1
+                 )
+import           Data.Multihash (Multihashable)
+import qualified Data.Multihash as Multihash
+
+
+objectLinks :: Multihashable hash => Object hash -> Vector CID
+objectLinks = \case
+    ObjCommit   x -> commitLinks x
+    ObjTag      x -> tagLinks    x
+    ObjBlob     x -> blobLinks   x
+    ObjTree     x -> treeLinks   x
+    -- XXX: not sure, possibly useful in the future
+    ObjDeltaOfs _ -> mempty
+    ObjDeltaRef _ -> mempty
+
+treeLinks :: forall hash. Multihashable hash => Tree hash -> Vector CID
+treeLinks (Tree entries) = foldMap (Vector.singleton . cid) entries
+  where
+    cid (_,_,ref) = refToCid @hash ref
+
+commitLinks :: Multihashable hash => Commit hash -> Vector CID
+commitLinks Commit { commitTreeish, commitParents } =
+       Vector.singleton (refToCid commitTreeish)
+    <> Vector.fromList  (map refToCid commitParents)
+
+tagLinks :: Multihashable hash => Tag hash -> Vector CID
+tagLinks = Vector.singleton . refToCid . tagRef
+
+blobLinks :: Blob hash -> Vector CID
+blobLinks = const mempty
+
+--------------------------------------------------------------------------------
+
+cidFromHexShaText :: Text -> Either String CID
+cidFromHexShaText t = do
+    bytes  <- BaseN.decodeBase16Either $ Text.encodeUtf8 t
+    digest <- note ("Invalid digest: " <> Text.unpack t) $ C.digestFromByteString @Git.SHA1 bytes
+    pure $ newCidV1 GitRaw digest
+
+hexShaFromCidText :: Text -> Either String Text
+hexShaFromCidText t = do
+    cid <- cidFromText t
+    Text.pack . Git.toHexString <$> cidToRef @Git.SHA1 cid
+
+refToCid :: forall hash. Multihashable hash => Git.Ref hash -> CID
+refToCid =
+    newCidV1 GitRaw
+        . fromMaybe (error "refToCid: Subverted Git.Ref supplied")
+        . C.digestFromByteString @hash
+        . Git.toBinary
+
+cidToRef :: Multihashable hash => CID -> Either String (Git.Ref hash)
+cidToRef cid =
+    case cidCodec cid of
+        GitRaw ->
+            fmap Git.fromDigest
+                . Multihash.decodeDigest
+                . Multihash.encodedBytes
+                $ cidHash cid
+        codec  -> Left $ "cidToRef: Unexpected codec `" <> show codec <> "`"
+
+--------------------------------------------------------------------------------
+
+note :: a -> Maybe b -> Either a b
+note l = maybe (Left l) Right
+
+hush :: Either a b -> Maybe b
+hush = either (const Nothing) pure
diff --git a/src/Network/IPFS/Git/RemoteHelper/Options.hs b/src/Network/IPFS/Git/RemoteHelper/Options.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/IPFS/Git/RemoteHelper/Options.hs
@@ -0,0 +1,167 @@
+{-# LANGUAGE DeriveGeneric     #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Network.IPFS.Git.RemoteHelper.Options where
+
+import           Control.Applicative (liftA2, (<|>))
+import           Control.Exception.Safe (throwString)
+import           Control.Monad.Trans.Maybe (MaybeT(..))
+import           Data.Function (on)
+import           Data.Git (withCurrentRepo)
+import qualified Data.Git.Repository as Git
+import           Data.IPLD.CID (CID, cidFromText)
+import           Data.List (dropWhileEnd)
+import           Data.Monoid (Last(..))
+import           Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Generics.SOP as SOP
+import           GHC.Generics (Generic)
+import           GHC.Stack (HasCallStack)
+import           Network.URI
+                 ( parseURI
+                 , uriAuthority
+                 , uriPath
+                 , uriRegName
+                 , uriScheme
+                 )
+import           Options.Applicative
+                 ( Parser
+                 , ReadM
+                 , argument
+                 , eitherReader
+                 , metavar
+                 , strArgument
+                 )
+import           Servant.Client (BaseUrl(..), Scheme(Http), parseBaseUrl)
+import           System.Environment (lookupEnv)
+import           Text.Read (readMaybe)
+
+import           Network.IPFS.Git.RemoteHelper.Generic
+                 ( HKD
+                 , ginvalidate
+                 , gvalidate
+                 )
+import           Network.IPFS.Git.RemoteHelper.Internal (note)
+
+
+data Options = Options
+    { optRemoteName :: String
+    , optRemoteUrl  :: RemoteUrl
+    }
+
+data RemoteUrl = RemoteUrl
+    { remoteUrlScheme   :: Text
+    , remoteUrlIpfsPath :: IpfsPath
+    }
+
+data IpfsPath
+    = IpfsPathIpfs CID
+    | IpfsPathIpns Text
+
+type IpfsOptions = IpfsOptions' SOP.I
+
+data IpfsOptions' f = IpfsOptions
+    { ipfsApiUrl       :: HKD f BaseUrl
+    -- ^ URL of the IPFS daemon API.
+    --
+    -- Default: \"http://localhost:5001\"
+    , ipfsMaxConns     :: HKD f Int
+    -- ^ Maximum number of concurrent connections to the IPFS daemon. Note that
+    -- this is approximate.
+    --
+    -- Default: 30
+    , ipfsMaxBlockSize :: HKD f Int
+    -- ^ The maximum size of an IPFS block. This is configurable as there is no
+    -- unambiguous documentation on what the actual value is. It may also be
+    -- subject to change in the future.
+    --
+    -- Default: 2048000 (2MB)
+    } deriving Generic
+
+instance SOP.Generic (IpfsOptions' f)
+
+instance Semigroup (IpfsOptions' Last) where
+    a <> b = IpfsOptions
+        { ipfsApiUrl       = on (<>) ipfsApiUrl       a b
+        , ipfsMaxConns     = on (<>) ipfsMaxConns     a b
+        , ipfsMaxBlockSize = on (<>) ipfsMaxBlockSize a b
+        }
+
+instance Monoid (IpfsOptions' Last) where
+    mempty  = IpfsOptions mempty mempty mempty
+    mappend = (<>)
+
+defaultIpfsOptions :: IpfsOptions
+defaultIpfsOptions = IpfsOptions
+    { ipfsApiUrl       = BaseUrl Http "localhost" 5001 mempty
+    , ipfsMaxConns     = 30
+    , ipfsMaxBlockSize = 2048000
+    }
+
+parseOptions :: Parser Options
+parseOptions = liftA2 Options
+    (strArgument (metavar "REMOTE_NAME"))
+    (argument remoteUrl (metavar "URL"))
+
+remoteUrl :: ReadM RemoteUrl
+remoteUrl = eitherReader $ \s -> do
+    uri  <- note "Invalid URI" $ parseURI s
+    let path = dropWhile (== '/') $ uriPath uri
+    ipfs <-
+        case uriRegName <$> uriAuthority uri of
+            Just "ipns" -> pure . IpfsPathIpns . Text.pack $ path
+            _           -> IpfsPathIpfs <$> cidFromString path
+    pure RemoteUrl
+        { remoteUrlScheme   = Text.pack . dropWhileEnd (== ':') $ uriScheme uri
+        , remoteUrlIpfsPath = ipfs
+        }
+  where
+    cidFromString = cidFromText . \case
+        [] -> emptyRepo
+        xs -> Text.pack xs
+
+    emptyRepo = "QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn"
+
+-- | Determine the 'IpfsOptions'.
+--
+-- This must happen after 'parseOptions' in order to support per-remote
+-- settings. The @GIT_DIR@ environment variable must be set and point to a valid
+-- git repository (when the remote helper is invoked by git, this is the current
+-- repository).
+--
+-- 'IpfsOptions' are configured using @git-config(2)@. The precedence rules
+-- specified there apply. However, @$XDG_CONFIG_HOME/git/config@ and
+-- @$(prefix)/etc/gitconfig@ (i.e. @--system@) are __not__ yet supported.
+--
+-- The available configuration keys are:
+--
+-- * @ipfs.apiurl@
+-- * @ipfs.maxconnections@
+-- * @ipfs.maxblocksize@
+--
+-- 'ipfsApiUrl' may be overridden per-remote using the key @remote.<remote
+-- name>.ipfsapiurl@ (e.g. @remote.origin.ipfsapiurl@). If the environment
+-- variable @IPFS_API_URL@, it will be used instead of any @git-config@
+-- settings.
+--
+getIpfsOptions :: HasCallStack => Options -> IO IpfsOptions
+getIpfsOptions Options { optRemoteName } = withCurrentRepo $ \r -> do
+    ipfsApiUrl <-
+        fmap Last . runMaybeT $ do
+            url <-
+                    MaybeT (lookupEnv "IPFS_API_URL")
+                <|> MaybeT (Git.configGet r ("remote \"" <> optRemoteName <> "\"") "ipfsapiurl")
+                <|> MaybeT (Git.configGet r "ipfs" "apiurl")
+            parseBaseUrl url
+
+    ipfsMaxConns <-
+        Last . (>>= readMaybe) <$> Git.configGet r "ipfs" "maxconnections"
+    ipfsMaxBlockSize <-
+        Last . (>>= readMaybe) <$> Git.configGet r "ipfs" "maxblocksize"
+
+    maybe (throwString "Das Unmögliche ist eingetreten: Invalid IpfsOptions")
+          pure
+        . getLast
+        . gvalidate
+        $ ginvalidate pure defaultIpfsOptions <> IpfsOptions {..}
diff --git a/src/Network/IPFS/Git/RemoteHelper/Trans.hs b/src/Network/IPFS/Git/RemoteHelper/Trans.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/IPFS/Git/RemoteHelper/Trans.hs
@@ -0,0 +1,325 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings          #-}
+{-# LANGUAGE TypeFamilies               #-}
+
+module Network.IPFS.Git.RemoteHelper.Trans
+    ( Logger (..)
+    , defaultLogger
+
+    , Env
+    , newEnv
+    , envVerbosity
+    , envDryRun
+    , envOptions
+    , envIpfsOptions
+    , envGit
+    , envClient
+    , envIpfsRoot
+    , envLobs
+
+    , RemoteHelper
+    , RemoteHelperT
+    , runRemoteHelper
+    , runRemoteHelperT
+
+    , DisplayError (..)
+
+    , RemoteHelperError
+    , errError
+    , errCallStack
+    , throwRH
+    , catchRH
+    , mapError
+    , liftEitherRH
+
+    , concurrently
+    , concurrently_
+    , forConcurrently_
+    , forConcurrently
+
+    , logInfo
+    , logDebug
+    , logError
+
+    , renderSourceLoc
+    )
+where
+
+import qualified Control.Concurrent.Async as Async
+import           Control.Concurrent.MVar (MVar, newMVar, withMVar)
+import           Control.Exception.Safe
+import qualified Control.Lens as Lens
+import           Control.Monad.Except
+import           Control.Monad.Primitive
+import           Control.Monad.Reader
+import qualified Data.Aeson.Lens as Lens
+import           Data.Bifunctor (first)
+import           Data.HashMap.Strict (HashMap)
+import           Data.IORef (IORef, newIORef, readIORef)
+import           Data.Maybe (fromMaybe)
+import           Data.Proxy (Proxy(..))
+import           Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.IO as Text
+import           GHC.Stack
+                 ( CallStack
+                 , HasCallStack
+                 , callStack
+                 , freezeCallStack
+                 , getCallStack
+                 , srcLocFile
+                 , srcLocStartLine
+                 )
+import           System.IO (stderr)
+
+import           Data.Git (Git)
+import           Data.Git.Monad (GitMonad(..))
+import           Data.Git.Ref (SHA1)
+import           Data.Git.Storage (findRepo, openRepo)
+
+import           Network.HTTP.Client
+                 ( defaultManagerSettings
+                 , managerResponseTimeout
+                 , newManager
+                 , responseTimeoutNone
+                 )
+import           Servant.Client.Streaming (mkClientEnv)
+import qualified Servant.Client.Streaming as Servant
+
+import           Data.IPLD.CID (CID, cidFromText)
+import           Network.IPFS.API (ApiV0NameResolve)
+
+import           Network.IPFS.Git.RemoteHelper.Internal (note)
+import           Network.IPFS.Git.RemoteHelper.Options
+
+data Logger = Logger
+    { _logInfo  :: Text -> IO ()
+    , _logDebug :: Text -> IO ()
+    , _logError :: Text -> IO ()
+    }
+
+data Env = Env
+    { envVerbosity   :: IORef Word
+    , envDryRun      :: IORef Bool
+    , envOptions     :: Options
+    , envIpfsOptions :: IpfsOptions
+    , envLogger      :: Logger
+    , envGit         :: Git SHA1
+    , envGitMutex    :: MVar ()
+    , envClient      :: Servant.ClientEnv
+    , envIpfsRoot    :: CID
+    , envLobs        :: MVar (Maybe (HashMap CID CID))
+    }
+
+class DisplayError a where
+    displayError :: a -> Text
+
+data RemoteHelperError a = RemoteHelperError
+    { errCallStack :: CallStack
+    , errError     :: a
+    } deriving Show
+
+instance
+    (Show a, Typeable a, DisplayError a)
+    => Exception (RemoteHelperError a)
+  where
+    displayException = Text.unpack . displayError
+
+instance DisplayError a => DisplayError (RemoteHelperError a) where
+    displayError e =
+        displayError (errError e) <> " " <> renderSourceLoc (errCallStack e)
+
+type RemoteHelper e = RemoteHelperT e IO
+
+newtype RemoteHelperT e m a = RemoteHelperT
+    { unRemoteHelperT :: ExceptT (RemoteHelperError e) (ReaderT Env m) a
+    } deriving ( Functor
+               , Applicative
+               , Monad
+               , MonadIO
+               , MonadReader Env
+               , MonadError  (RemoteHelperError e)
+               , MonadThrow
+               , MonadCatch
+               , MonadMask
+               )
+
+instance MonadTrans (RemoteHelperT e) where
+    lift = RemoteHelperT . lift . lift
+    {-# INLINE lift #-}
+
+instance PrimMonad m => PrimMonad (RemoteHelperT e m) where
+    type PrimState (RemoteHelperT e m) = PrimState m
+    primitive = lift . primitive
+    {-# INLINE primitive #-}
+
+instance MonadIO m => GitMonad (RemoteHelperT e m) where
+    getGit    = asks envGit
+    liftGit f = do
+        lck <- asks envGitMutex
+        liftIO . withMVar lck . const $ f
+
+    {-# INLINE getGit  #-}
+    {-# INLINE liftGit #-}
+
+remoteHelperError :: CallStack -> a -> RemoteHelperError a
+remoteHelperError cs e = RemoteHelperError
+    { errCallStack = cs
+    , errError     = e
+    }
+
+throwRH :: (Monad m, HasCallStack) => e -> RemoteHelperT e m a
+throwRH = throwError . remoteHelperError (freezeCallStack callStack)
+
+catchRH
+    :: Monad m
+    => RemoteHelperT e m a
+    -> (e -> RemoteHelperT e m a)
+    -> RemoteHelperT e m a
+catchRH ma f = catchError ma (f . errError)
+
+mapError
+    :: Monad m
+    => (e -> e')
+    -> RemoteHelperT e m a
+    -> RemoteHelperT e' m a
+mapError f (RemoteHelperT ma) =
+    RemoteHelperT $ flip withExceptT ma $ \e ->
+        e { errError = f (errError e) }
+
+liftEitherRH :: (Monad m, HasCallStack) => Either e a -> RemoteHelperT e m a
+liftEitherRH =
+    liftEither . first (remoteHelperError (freezeCallStack callStack))
+
+concurrently
+    :: (Show e, Typeable e, DisplayError e)
+    => RemoteHelperT e IO a
+    -> RemoteHelperT e IO b
+    -> RemoteHelperT e IO (a, b)
+concurrently left right = do
+    env <- ask
+    liftIO $ Async.concurrently
+        (either throwM pure =<< runRemoteHelperT env left)
+        (either throwM pure =<< runRemoteHelperT env right)
+
+concurrently_
+    :: (Show e, Typeable e, DisplayError e)
+    => RemoteHelperT e IO a
+    -> RemoteHelperT e IO b
+    -> RemoteHelperT e IO ()
+concurrently_ left right = do
+    env <- ask
+    liftIO $ Async.concurrently_
+        (either throwM pure =<< runRemoteHelperT env left)
+        (either throwM pure =<< runRemoteHelperT env right)
+
+forConcurrently_
+    :: ( Foldable     t
+       , Show         e
+       , Typeable     e
+       , DisplayError e
+       )
+    => t a
+    -> (a -> RemoteHelperT e IO b)
+    -> RemoteHelperT e IO ()
+forConcurrently_ xs f = do
+    env <- ask
+    liftIO $
+        Async.forConcurrently_ xs $ \x ->
+            either throwM pure =<< runRemoteHelperT env (f x)
+
+forConcurrently
+    :: ( Traversable  t
+       , Show         e
+       , Typeable     e
+       , DisplayError e
+       )
+    => t a
+    -> (a -> RemoteHelperT e IO b)
+    -> RemoteHelperT e IO (t b)
+forConcurrently xs f = do
+    env <- ask
+    liftIO $
+        Async.forConcurrently xs $ \x ->
+            either throwM pure =<< runRemoteHelperT env (f x)
+
+logInfo :: MonadIO m => Text -> RemoteHelperT e m ()
+logInfo msg = do
+    out <- asks $ _logInfo . envLogger
+    v   <- liftIO . readIORef =<< asks envVerbosity
+    when (v > 0) $
+        liftIO $ out msg
+
+logDebug :: (HasCallStack, MonadIO m) => Text -> RemoteHelperT e m ()
+logDebug msg = do
+    out <- asks $ _logDebug . envLogger
+    v   <- liftIO . readIORef =<< asks envVerbosity
+    when (v > 1) $
+        liftIO . out $ msg <> renderSourceLoc callStack
+
+logError :: (HasCallStack, MonadIO m) => Text -> RemoteHelperT e m ()
+logError msg = do
+    out <- asks $ _logError . envLogger
+    liftIO . out $ msg <> renderSourceLoc callStack
+
+renderSourceLoc :: CallStack -> Text
+renderSourceLoc cs =
+    case getCallStack cs of
+        ((_, loc) : _) ->
+            " (" <> Text.pack (srcLocFile loc)
+                 <> ":"
+                 <> Text.pack (show (srcLocStartLine loc))
+          <> ")"
+        _ ->
+            " (<unknown>)"
+
+defaultLogger :: Logger
+defaultLogger = Logger out out out
+  where
+    out = Text.hPutStrLn stderr
+
+newEnv :: HasCallStack => Logger -> Options -> IpfsOptions -> IO Env
+newEnv envLogger envOptions envIpfsOptions = do
+    envVerbosity <- newIORef 1
+    envDryRun    <- newIORef False
+    envGit       <- findRepo >>= openRepo
+    envGitMutex  <- newMVar ()
+    envLobs      <- newMVar Nothing
+    envClient    <-
+        flip mkClientEnv (ipfsApiUrl envIpfsOptions)
+            <$> newManager defaultManagerSettings
+                    { managerResponseTimeout = responseTimeoutNone }
+    envIpfsRoot  <-
+        case remoteUrlIpfsPath (optRemoteUrl envOptions) of
+            IpfsPathIpfs cid  -> pure cid
+            IpfsPathIpns name -> do
+                _logInfo envLogger $ "Resolving IPNS name " <> name
+                res <-
+                    flip Servant.runClientM envClient $
+                        ipfsNameResolve name
+                                        (Just True)  -- recursive
+                                        Nothing
+                                        Nothing
+                                        Nothing
+                case res of
+                    Left  e -> throwM e
+                    Right v -> either throwString pure $ do
+                        path <-
+                            note "ipfsNameResolve: expected 'Path' key" $
+                                Lens.firstOf (Lens.key "Path" . Lens._String) v
+                        cidFromText
+                            . fromMaybe path
+                            $ Text.stripPrefix "/ipfs/" path
+
+    pure Env {..}
+  where
+    ipfsNameResolve = Servant.client (Proxy @ApiV0NameResolve)
+
+runRemoteHelperT
+    :: Env
+    -> RemoteHelperT e m a
+    -> m (Either (RemoteHelperError e) a)
+runRemoteHelperT r = flip runReaderT r . runExceptT . unRemoteHelperT
+
+runRemoteHelper :: Env -> RemoteHelper e a -> IO (Either (RemoteHelperError e) a)
+runRemoteHelper = runRemoteHelperT
diff --git a/test/e2e/Main.hs b/test/e2e/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/e2e/Main.hs
@@ -0,0 +1,223 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeOperators     #-}
+{-# LANGUAGE ViewPatterns      #-}
+
+{-# OPTIONS_GHC -fno-warn-missing-signatures #-}
+
+module Main where
+
+import           Control.Applicative (liftA2)
+import           Control.Exception.Safe (throwM, throwString)
+import           Control.Lens (firstOf)
+import           Control.Monad (void)
+import           Data.Aeson.Lens (key, _String)
+import           Data.ByteString (ByteString)
+import qualified Data.ByteString.Char8 as Bytes
+import           Data.ByteString.Lazy (fromStrict, toStrict)
+import qualified Data.ByteString.Lazy.Char8 as Lazy
+import           Data.Foldable (foldlM)
+import qualified Data.Git.Monad as Git
+import           Data.Git.Ref (SHA1)
+import qualified Data.Git.Storage as Git (initRepo)
+import           Data.Git.Types (GitTime(..))
+import           Data.Maybe (fromMaybe)
+import           Data.Proxy (Proxy(..))
+import           Data.String (fromString)
+import           Data.Text (Text)
+import qualified Data.Text as Text
+import           Data.Text.Encoding (decodeUtf8)
+import           GHC.Stack (HasCallStack)
+import qualified Network.HTTP.Client as Http
+import           Network.IPFS.API (ApiV0KeyGen, ApiV0NamePublish)
+import           Servant.API
+import qualified Servant.Client as Servant
+import           System.Entropy (getEntropy)
+import           System.Environment (lookupEnv)
+import           System.Exit (ExitCode(..))
+import           System.FilePath (takeBaseName, takeDirectory, (</>))
+import           System.Hourglass (timeCurrent, timezoneCurrent)
+import           System.IO.Temp (withSystemTempDirectory)
+import           System.Process.Typed
+
+import           Test.Tasty
+import           Test.Tasty.HUnit
+
+type Step = String -> IO ()
+
+main :: IO ()
+main =
+    withSystemTempDirectory "git-remote-ipfs-e2e" $ \tmp -> defaultMain $
+        testGroup "E2E Tests"
+            [ testCaseSteps "Push, clone: IPFS"     $ testPushCloneSimple tmp
+            , testCaseSteps "Push, clone: IPNS"     $ testPushCloneIPNS   tmp
+            , testCaseSteps "Push, clone: LOB IPFS" $ testLargeObjects    tmp
+            ]
+
+testPushCloneSimple :: FilePath -> Step -> IO ()
+testPushCloneSimple root step = runPushCloneTest PushCloneOpts
+    { pcoRepo      = root </> "simple" </> ".git"
+    , pcoClone     = root </> "simple-clone" </> ".git"
+    , pcoRemoteUrl = "ipfs://"
+    , pcoHistory   = simpleHistory
+    , pcoLog       = step
+    }
+
+testPushCloneIPNS :: FilePath -> Step -> IO ()
+testPushCloneIPNS root step = do
+    let keyName = Text.pack (takeBaseName root) -- piggypack on randomness of tmp
+    step "Creating IPNS name"
+    ipnsName <- Text.unpack <$> ipfs (createIpnsName keyName)
+    runPushCloneTest PushCloneOpts
+        { pcoRepo      = root </> ipnsName </> ".git"
+        , pcoClone     = root </> ipnsName ++ "-clone" </> ".git"
+        , pcoRemoteUrl = "ipfs://ipns/" <> ipnsName
+        , pcoHistory   = simpleHistory
+        , pcoLog       = step
+        }
+  where
+    ipfs m = servantEnv >>= Servant.runClientM m >>= either throwM pure
+
+testLargeObjects :: FilePath -> Step -> IO ()
+testLargeObjects root step = runPushCloneTest PushCloneOpts
+    { pcoRepo      = root </> "lobs" </> ".git"
+    , pcoClone     = root </> "lobs-clone" </> ".git"
+    , pcoRemoteUrl = "ipfs://"
+    , pcoHistory   = lobHistory
+    , pcoLog       = step
+    }
+
+data PushCloneOpts = PushCloneOpts
+    { pcoRepo      :: FilePath
+    , pcoClone     :: FilePath
+    , pcoRemoteUrl :: String
+    , pcoHistory   :: FilePath -> IO ()
+    , pcoLog       :: Step
+    }
+
+runPushCloneTest :: PushCloneOpts -> IO ()
+runPushCloneTest PushCloneOpts{..} = do
+    initRepo pcoLog pcoRepo pcoHistory
+
+    url <- pushIpfs pcoLog pcoRepo pcoRemoteUrl
+    cloneIpfs pcoLog (takeDirectory pcoClone) url
+
+    assertSameRepos pcoLog pcoRepo pcoClone
+
+initRepo :: HasCallStack => Step -> FilePath -> (FilePath -> IO ()) -> IO ()
+initRepo step repo history = do
+    step $ "Initializing repo at " <> repo
+    let repo' = fromString repo
+    Git.initRepo repo'
+    (>>= either throwString pure) . Git.withRepo repo' $
+        Git.headSet $ pure "master"
+    history repo
+
+pushIpfs :: Step -> FilePath -> String -> IO String
+pushIpfs step repo url = do
+    step $ "Pushing master to " <> url
+    git_ repo ["remote", "add", "origin", url]
+    git_ repo ["push", "--quiet", "origin", "master"]
+    Text.unpack . Text.strip . decodeUtf8
+        <$> git repo ["remote", "get-url", "origin"]
+
+cloneIpfs :: Step -> FilePath -> String -> IO ()
+cloneIpfs step repo url = do
+    step $ "Cloning " <> url <> " to " <> repo
+    git_ repo ["clone", "--quiet", url, repo]
+
+assertSameRepos :: Step -> FilePath -> FilePath -> IO ()
+assertSameRepos step src clone = do
+    step $ "Fetching " <> src <> " to " <> clone
+    git_ clone ["remote", "add", "src", src]
+    git_ clone ["fetch", "--quiet", "src"]
+    step "Comparing origin/master src/master"
+    void $ gitAssert "Source and cloned repository differ"
+        clone ["diff", "--quiet", "origin/master", "src/master"]
+
+simpleHistory :: FilePath -> IO ()
+simpleHistory repo =
+    (>>= either throwString (const $ pure ())) . Git.withRepo (fromString repo) $ do
+        initial <- mkCommit Nothing Nothing 0
+        foldlM (\(bs, rev) i -> mkCommit bs rev i) initial [1..42]
+  where
+    mkCommit
+        :: Maybe Lazy.ByteString
+        -> Maybe String
+        -> Int
+        -> Git.GitM (Maybe Lazy.ByteString, Maybe String)
+    mkCommit readme branch (show -> i) = do
+        person  <- mkPerson <$> currentTimeGit
+        (r, ()) <-
+            Git.withNewCommit person branch $ do
+                Git.setMessage $ "Commit " <> Bytes.pack i
+                Git.setFile ["README"] $
+                    fromMaybe mempty readme <> Lazy.pack i <> "\n"
+        Git.branchWrite "master" r
+        (,Just "master") <$>
+            Git.withCommit ("master" :: String) (Git.getFile ["README"])
+
+lobHistory :: FilePath -> IO ()
+lobHistory repo = do
+    blob <- getEntropy 4096000
+    (>>= either throwString pure) . Git.withRepo (fromString repo) $ do
+        person  <- mkPerson <$> currentTimeGit
+        (r, ()) <-
+            Git.withNewCommit person (Nothing :: Maybe (Git.Ref SHA1)) $ do
+                Git.setMessage "Huuuuge"
+                Git.setFile ["huge.file"] $ fromStrict blob
+        Git.branchWrite "master" r
+
+mkPerson :: GitTime -> Git.Person
+mkPerson = Git.Person "LeBoeuf" "le@boe.uf"
+
+currentTimeGit :: Git.GitM GitTime
+currentTimeGit = Git.liftGit $ liftA2 GitTime timeCurrent timezoneCurrent
+
+git :: FilePath -> [String] -> IO ByteString
+git = gitAssert mempty
+
+git_ :: FilePath -> [String] -> IO ()
+git_ repo args = void $ git repo args
+
+gitAssert :: String -> FilePath -> [String] -> IO ByteString
+gitAssert msg repo args = do
+    let cfg = proc "git" $ "--git-dir" : repo : args
+    readProcess cfg >>= \case
+        (ExitSuccess, out, _err) -> pure $ toStrict out
+        (failure    , out, err ) -> assertFailure $ unlines
+            [ msg
+            , show failure
+            , show cfg
+            , show out
+            , show err
+            ]
+
+type IPFS = ApiV0KeyGen :<|> ApiV0NamePublish
+
+ipfsKeyGen :<|> ipfsNamePublish = Servant.client (Proxy @IPFS)
+
+servantEnv :: IO Servant.ClientEnv
+servantEnv = liftA2 Servant.mkClientEnv mgr base
+  where
+    mgr = Http.newManager Http.defaultManagerSettings
+            { Http.managerResponseTimeout = Http.responseTimeoutNone }
+
+    base =
+        Servant.parseBaseUrl
+            =<< fromMaybe "http://localhost:5001" <$> lookupEnv "IPFS_API_URL"
+
+createIpnsName :: HasCallStack => Text -> Servant.ClientM Text
+createIpnsName keyName = do
+    keyId <-
+        maybe (throwString "Missing key Id") pure
+            . firstOf (key "Id" . _String)
+            =<< ipfsKeyGen keyName (Just "ed25519") Nothing
+    void $
+        ipfsNamePublish ("/ipfs/" <> emptyRepo)
+                        (Just True)  -- resolve
+                        (Just "5m")  -- lifetime
+                        Nothing      -- caching
+                        (Just keyId) -- key
+    pure keyId
+  where
+    emptyRepo = "QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn"
