diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,6 @@
 
 # Generated shell completion rulesets.
 /support/shell/rules/
+
+# Python extension compiled files.
+/support/hg/arc-hg.pyc
diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php
--- a/src/land/engine/ArcanistMercurialLandEngine.php
+++ b/src/land/engine/ArcanistMercurialLandEngine.php
@@ -7,6 +7,16 @@
     $api = $this->getRepositoryAPI();
     $log = $this->getLogEngine();
 
+    // TODO: In Mercurial, you normally can not create a branch and a bookmark
+    // with the same name. However, you can fetch a branch or bookmark from
+    // a remote that has the same name as a local branch or bookmark of the
+    // other type, and end up with a local branch and bookmark with the same
+    // name. We should detect this and treat it as an error.
+
+    // TODO: In Mercurial, you can create local bookmarks named
+    // "default@default" and similar which do not surive a round trip through
+    // a remote. Possibly, we should disallow interacting with these bookmarks.
+
     $markers = $api->newMarkerRefQuery()
       ->withIsActive(true)
       ->execute();
@@ -436,27 +446,156 @@
     $api = $this->getRepositoryAPI();
     $log = $this->getLogEngine();
 
-    // TODO: Support bookmarks.
-    // TODO: Deal with bookmark save/restore behavior.
-    // TODO: Raise a good error message when the ref does not exist.
+    // See T9948. If the user specified "--into X", we don't know if it's a
+    // branch, a bookmark, or a symbol which doesn't exist yet.
+
+    // In native Mercurial it is difficult to figure this out, so we use
+    // an extension to provide a command which works like "git ls-remote".
+
+    // NOTE: We're using passthru on this because it's a remote command and
+    // may prompt the user for credentials.
+
+    // TODO: This is fairly silly/confusing to show to users in the common
+    // case where it does not require credentials, particularly because the
+    // actual command line is full of nonsense.
+
+    $tmpfile = new TempFile();
+    Filesystem::remove($tmpfile);
 
     $err = $this->newPassthru(
-      'pull -b %s -- %s',
-      $target->getRef(),
+      '%Ls arc-ls-remote --output %s -- %s',
+      $api->getMercurialExtensionArguments(),
+      phutil_string_cast($tmpfile),
       $target->getRemote());
+    if ($err) {
+      throw new Exception(
+        pht(
+          'Call to "hg arc-ls-remote" failed with error "%s".',
+          $err));
+    }
+
+    $raw_data = Filesystem::readFile($tmpfile);
+    unset($tmpfile);
+
+    $markers = phutil_json_decode($raw_data);
+
+    $target_name = $target->getRef();
+
+    $bookmarks = array();
+    $branches = array();
+    foreach ($markers as $marker) {
+      if ($marker['name'] !== $target_name) {
+        continue;
+      }
+
+      if ($marker['type'] === 'bookmark') {
+        $bookmarks[] = $marker;
+      } else {
+        $branches[] = $marker;
+      }
+    }
+
+    if (!$bookmarks && !$branches) {
+      throw new PhutilArgumentUsageException(
+        pht(
+          'Remote "%s" has no bookmark or branch named "%s".',
+          $target->getRemote(),
+          $target->getRef()));
+    }
+
+    if ($bookmarks && $branches) {
+      echo tsprintf(
+        "\n%!\n%W\n\n",
+        pht('AMBIGUOUS MARKER'),
+        pht(
+          'In remote "%s", the name "%s" identifies one or more branch '.
+          'heads and one or more bookmarks. Close, rename, or delete all '.
+          'but one of these markers, or pull the state you want to merge '.
+          'into and use "--into-local --into <hash>" to disambiguate the '.
+          'desired merge target.',
+          $target->getRemote(),
+          $target->getRef()));
+
+      throw new PhutilArgumentUsageException(
+        pht('Merge target is ambiguous.'));
+    }
+
+    $is_bookmark = false;
+    $is_branch = false;
+
+    if ($bookmarks) {
+      if (count($bookmarks) > 1) {
+        throw new Exception(
+          pht(
+            'Remote "%s" has multiple bookmarks with name "%s". This '.
+            'is unexpected.',
+            $target->getRemote(),
+            $target->getRef()));
+      }
+      $bookmark = head($bookmarks);
+
+      $target_hash = $bookmark['node'];
+      $is_bookmark = true;
+    }
+
+    if ($branches) {
+      if (count($branches) > 1) {
+        echo tsprintf(
+          "\n%!\n%W\n\n",
+          pht('MULTIPLE BRANCH HEADS'),
+          pht(
+            'Remote "%s" has multiple branch heads named "%s". Close all '.
+            'but one, or pull the head you want and use "--into-local '.
+            '--into <hash>" to specify an explicit merge target.',
+            $target->getRemote(),
+            $target->getRef()));
 
-    // TODO: Deal with errors.
-    // TODO: Deal with multiple branch heads.
+        throw new PhutilArgumentUsageException(
+          pht(
+            'Remote branch has multiple heads.'));
+      }
+
+      $branch = head($branches);
 
-    list($stdout) = $api->execxLocal(
-      'log --rev %s --template %s --',
-      hgsprintf(
-        'last(ancestors(%s) and !outgoing(%s))',
+      $target_hash = $branch['node'];
+      $is_branch = true;
+    }
+
+    if ($is_branch) {
+      $err = $this->newPassthru(
+        'pull -b %s -- %s',
         $target->getRef(),
-        $target->getRemote()),
-      '{node}');
+        $target->getRemote());
+    } else {
+
+      // NOTE: This may have side effects:
+      //
+      //   - It can create a "bookmark@remote" bookmark if there is a local
+      //     bookmark with the same name that is not an ancestor.
+      //   - It can create an arbitrary number of other bookmarks.
+      //
+      // Since these seem to generally be intentional behaviors in Mercurial,
+      // and should theoretically be familiar to Mercurial users, just accept
+      // them as the cost of doing business.
+
+      $err = $this->newPassthru(
+        'pull -B %s -- %s',
+        $target->getRef(),
+        $target->getRemote());
+    }
+
+    // NOTE: It's possible that between the time we ran "ls-remote" and the
+    // time we ran "pull" that the remote changed.
+
+    // It may even have been rewound or rewritten, in which case we did not
+    // actually fetch the ref we are about to return as a target. For now,
+    // assume this didn't happen: it's so unlikely that it's probably not
+    // worth spending 100ms to check.
+
+    // TODO: If the Mercurial command server is revived, this check becomes
+    // more reasonable if it's cheap.
 
-    return trim($stdout);
+    return $target_hash;
   }
 
   protected function selectCommits($into_commit, array $symbols) {
diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php
--- a/src/repository/api/ArcanistMercurialAPI.php
+++ b/src/repository/api/ArcanistMercurialAPI.php
@@ -1012,4 +1012,16 @@
     return new ArcanistMercurialRepositoryRemoteQuery();
   }
 
+
+  public function getMercurialExtensionArguments() {
+    $path = phutil_get_library_root('arcanist');
+    $path = dirname($path);
+    $path = $path.'/support/hg/arc-hg.py';
+
+    return array(
+      '--config',
+      'extensions.arc-hg='.$path,
+    );
+  }
+
 }
diff --git a/support/hg/arc-hg.py b/support/hg/arc-hg.py
new file mode 100644
--- /dev/null
+++ b/support/hg/arc-hg.py
@@ -0,0 +1,90 @@
+from __future__ import absolute_import
+
+import os
+import json
+
+from mercurial import (
+  cmdutil,
+  bookmarks,
+  bundlerepo,
+  error,
+  hg,
+  i18n,
+  node,
+  registrar,
+)
+
+_ = i18n._
+cmdtable = {}
+command = registrar.command(cmdtable)
+
+@command(
+  "arc-ls-remote",
+  [('', 'output', '',
+    _('file to output refs to'), _('FILE')),
+  ] + cmdutil.remoteopts,
+  _('[--output FILENAME] [SOURCE]'))
+def lsremote(ui, repo, source="default", **opts):
+  """list markers in a remote
+
+  Show the current branch heads and bookmarks in a specified path/URL or the
+  default pull location.
+
+  Markers are printed to stdout in JSON.
+
+  (This is an Arcanist extension to Mercurial.)
+
+  Returns 0 if listing the markers succeeds, 1 otherwise.
+  """
+
+  # Disable status output from fetching a remote.
+  ui.quiet = True
+
+  source, branches = hg.parseurl(ui.expandpath(source))
+  remote = hg.peer(repo, opts, source)
+
+  markers = []
+
+  bundle, remotebranches, cleanup = bundlerepo.getremotechanges(
+    ui,
+    repo,
+    remote)
+
+  try:
+    for n in remotebranches:
+      ctx = bundle[n]
+      markers.append({
+        'type': 'branch',
+        'name': ctx.branch(),
+        'node': node.hex(ctx.node()),
+      })
+  finally:
+    cleanup()
+
+  with remote.commandexecutor() as e:
+    remotemarks = bookmarks.unhexlifybookmarks(e.callcommand('listkeys', {
+        'namespace': 'bookmarks',
+    }).result())
+
+  for mark in remotemarks:
+    markers.append({
+      'type': 'bookmark',
+      'name': mark,
+      'node': node.hex(remotemarks[mark]),
+    })
+
+  json_opts = {
+    'indent': 2,
+    'sort_keys': True,
+  }
+
+  output_file = opts.get('output')
+  if output_file:
+    if os.path.exists(output_file):
+      raise error.Abort(_('File "%s" already exists.' % output_file))
+    with open(output_file, 'w+') as f:
+      json.dump(markers, f, **json_opts)
+  else:
+    print json.dumps(markers, output_file, **json_opts)
+
+  return 0