Patchwork D3533: wireprotov2: define and implement "filehistory" command

login
register
mail settings
Submitter phabricator
Date May 11, 2018, 10:35 p.m.
Message ID <differential-rev-PHID-DREV-l6jupj4skrl6g5ivyhzy-req@phab.mercurial-scm.org>
Download mbox | patch
Permalink /patch/31521/
State Superseded
Headers show

Comments

phabricator - May 11, 2018, 10:35 p.m.
indygreg created this revision.
Herald added a subscriber: mercurial-devel.
Herald added a reviewer: hg-reviewers.

REVISION SUMMARY
  There are two parts to file storage: index data and revision data.
  While the storage interfaces and implementation don't yet have a
  strong division between these concepts, that is the reality of how
  things are.
  
  It is tempting to say that partial clones will only care about
  files revision data. While this may be possible someday, initial
  implementations will almost certainly need local index data
  to facilitate various operations (such as `hg log <file>`). I
  foresee a future where a local repository only contains file
  index data and resolves revision data on-the-fly or even lazily
  resolves both.
  
  Anyway, I anticipate a need for clients to want to look up just
  the history data for a tracked path.
  
  This commit introduces a wire protocol command for obtaining
  the history data of a single tracked path. Given a set of starting
  file nodes, it walks backwards along the DAG and emits a history
  record for each file revision. Because clients may only want to
  fetch history that is new since the last fetch, the command supports
  specifying base nodes at which traversal should stop.
  
  There is an inline TODO about handling linknodes better. I'm
  honestly not sure how we should best resolve linknodes in this
  context. If we were to switch clones/pulls to use this API, we
  would experience bugs due to missing/incorrect linknode data.
  So we'll need to address this at some point. I'm open to
  suggestions...

REPOSITORY
  rHG Mercurial

REVISION DETAIL
  https://phab.mercurial-scm.org/D3533

AFFECTED FILES
  mercurial/help/internals/wireprotocol.txt
  mercurial/wireprotov2server.py
  tests/test-http-protocol.t
  tests/test-wireproto-command-capabilities.t
  tests/test-wireproto-command-filehistory.t

CHANGE DETAILS




To: indygreg, #hg-reviewers
Cc: mercurial-devel

Patch

diff --git a/tests/test-wireproto-command-filehistory.t b/tests/test-wireproto-command-filehistory.t
new file mode 100644
--- /dev/null
+++ b/tests/test-wireproto-command-filehistory.t
@@ -0,0 +1,279 @@ 
+  $ . $TESTDIR/wireprotohelpers.sh
+
+  $ hg init server
+  $ enablehttpv2 server
+
+  $ cd server
+
+  $ echo a0 > a
+  $ echo b0 > b
+  $ echo c0 > c
+  $ mkdir dir0
+  $ echo d0 > dir0/d
+  $ echo e1 > dir0/e
+  $ hg -q commit -A -m initial
+
+  $ echo a1 > a
+  $ hg commit -m 'commit 2'
+  $ echo a2 > a
+  $ hg commit -m 'commit 3'
+  $ hg up -r 0
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ echo a1-branches > a
+  $ hg commit -m 'commit 4 (new head)'
+  created new head
+
+  $ hg up 3
+  0 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ hg mv c c-moved
+  $ hg commit -m 'commit 5 (moved c)'
+
+  $ cd ..
+
+  $ hg -R server serve -p $HGPORT -d --pid-file hg.pid -E error.log
+  $ cat hg.pid > $DAEMON_PIDS
+
+Invalid path yields a sensible error
+
+  $ sendhttpv2peer << EOF
+  > command filehistory
+  >     path does/not/exist
+  >     filenodeheads eval:[]
+  >     filenodebases eval:[]
+  > EOF
+  creating http peer for wire protocol version 2
+  sending filehistory command
+  s>     POST /api/exp-http-v2-0001/ro/filehistory HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0005\r\n
+  s>     content-type: application/mercurial-exp-framing-0005\r\n
+  s>     content-length: 82\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     user-agent: Mercurial debugwireproto\r\n
+  s>     \r\n
+  s>     J\x00\x00\x01\x00\x01\x01\x11\xa2Dargs\xa3DpathNdoes/not/existMfilenodebases\x80Mfilenodeheads\x80DnameKfilehistory
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: application/mercurial-exp-framing-0005\r\n
+  s>     Transfer-Encoding: chunked\r\n
+  s>     \r\n
+  s>     4a\r\n
+  s>     B\x00\x00\x01\x00\x02\x012
+  s>     \xa2Eerror\xa2DargsNdoes/not/existGmessagePunknown file: %sFstatusEerror
+  s>     \r\n
+  received frame(size=66; request=1; stream=2; streamflags=stream-begin; type=command-response; flags=eos)
+  s>     0\r\n
+  s>     \r\n
+  response: [{b'error': {b'args': b'does/not/exist', b'message': b'unknown file: %s'}, b'status': b'error'}]
+
+Bad file node in heads yields a sensible error
+
+  $ sendhttpv2peer << EOF
+  > command filehistory
+  >    path a
+  >    filenodeheads eval:[b'\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa']
+  >    filenodebases eval:[]
+  > EOF
+  creating http peer for wire protocol version 2
+  sending filehistory command
+  s>     POST /api/exp-http-v2-0001/ro/filehistory HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0005\r\n
+  s>     content-type: application/mercurial-exp-framing-0005\r\n
+  s>     content-length: 90\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     user-agent: Mercurial debugwireproto\r\n
+  s>     \r\n
+  s>     R\x00\x00\x01\x00\x01\x01\x11\xa2Dargs\xa3DpathAaMfilenodebases\x80Mfilenodeheads\x81T\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaaDnameKfilehistory
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: application/mercurial-exp-framing-0005\r\n
+  s>     Transfer-Encoding: chunked\r\n
+  s>     \r\n
+  s>     5c\r\n
+  s>     T\x00\x00\x01\x00\x02\x012
+  s>     \xa2Eerror\xa2Dargs\x81T\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaaGmessageX\x1afailed to resolve node: %sFstatusEerror
+  s>     \r\n
+  received frame(size=84; request=1; stream=2; streamflags=stream-begin; type=command-response; flags=eos)
+  s>     0\r\n
+  s>     \r\n
+  response: [{b'error': {b'args': [b'\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa'], b'message': b'failed to resolve node: %s'}, b'status': b'error'}]
+
+Bad file node in bases yields a sensible error
+
+  $ sendhttpv2peer << EOF
+  > command filehistory
+  >    path a
+  >    filenodeheads eval:[]
+  >    filenodebases eval:[b'\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb']
+  > EOF
+  creating http peer for wire protocol version 2
+  sending filehistory command
+  s>     POST /api/exp-http-v2-0001/ro/filehistory HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0005\r\n
+  s>     content-type: application/mercurial-exp-framing-0005\r\n
+  s>     content-length: 90\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     user-agent: Mercurial debugwireproto\r\n
+  s>     \r\n
+  s>     R\x00\x00\x01\x00\x01\x01\x11\xa2Dargs\xa3DpathAaMfilenodebases\x81T\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbbMfilenodeheads\x80DnameKfilehistory
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: application/mercurial-exp-framing-0005\r\n
+  s>     Transfer-Encoding: chunked\r\n
+  s>     \r\n
+  s>     5c\r\n
+  s>     T\x00\x00\x01\x00\x02\x012
+  s>     \xa2Eerror\xa2Dargs\x81T\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbbGmessageX\x1afailed to resolve node: %sFstatusEerror
+  s>     \r\n
+  received frame(size=84; request=1; stream=2; streamflags=stream-begin; type=command-response; flags=eos)
+  s>     0\r\n
+  s>     \r\n
+  response: [{b'error': {b'args': [b'\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb\xbb'], b'message': b'failed to resolve node: %s'}, b'status': b'error'}]
+
+Requesting history with empty heads and bases retrieves full DAG
+
+  $ sendhttpv2peer << EOF
+  > command filehistory
+  >    path a
+  >    filenodeheads eval:[]
+  >    filenodebases eval:[]
+  > EOF
+  creating http peer for wire protocol version 2
+  sending filehistory command
+  s>     POST /api/exp-http-v2-0001/ro/filehistory HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0005\r\n
+  s>     content-type: application/mercurial-exp-framing-0005\r\n
+  s>     content-length: 69\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     user-agent: Mercurial debugwireproto\r\n
+  s>     \r\n
+  s>     =\x00\x00\x01\x00\x01\x01\x11\xa2Dargs\xa3DpathAaMfilenodebases\x80Mfilenodeheads\x80DnameKfilehistory
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: application/mercurial-exp-framing-0005\r\n
+  s>     Transfer-Encoding: chunked\r\n
+  s>     \r\n
+  s>     13\r\n
+  s>     \x0b\x00\x00\x01\x00\x02\x011
+  s>     \xa1FstatusBok
+  s>     \r\n
+  received frame(size=11; request=1; stream=2; streamflags=stream-begin; type=command-response; flags=continuation)
+  s>     1cc\r\n
+  s>     \xc4\x01\x00\x01\x00\x02\x000
+  s>     \xa4HlinknodeT\xfd\x8dO6\xd3@\xdb\x7f\xa4Y\x10\x078k\xac\x0c\xc1\xb2#\xd0DnodeT+N\xb0s\x19\xbf\xa0w\xa4\n
+  s>     /\x04\x916Y\xae\xf0\xdaB\xdaFp1nodeT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00Fp2nodeT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4HlinknodeT\x04\x03\xe9K\x8c\xe4e\xa1\xf6\x99;\xb1\xe4`{\xbe\x17`V\xa7DnodeT\x9a8\x12)\x97\xb3\xac\x97\xbe*\x9a\xa2\xe5V\x83\x83A\xfd\xf2\xccFp1nodeT+N\xb0s\x19\xbf\xa0w\xa4\n
+  s>     /\x04\x916Y\xae\xf0\xdaB\xdaFp2nodeT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4HlinknodeT\x0e\xb5\x06\x83\x84\x08E\xf3\xa7R\x8a}\xf3+l\xe2\x1c1J\x19DnodeT\xc2\xa2\x05\xc8\xb2\xad\xe2J\xf2`b\xe5<\xd5\xbc8\x01\xd6`\xdaFp1nodeT\x9a8\x12)\x97\xb3\xac\x97\xbe*\x9a\xa2\xe5V\x83\x83A\xfd\xf2\xccFp2nodeT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4HlinknodeT\x15_\xcd\xfdBs\xeb\xeb\\.T\xd1\xc8q\x1f\xfd\xc2\x993\xabDnodeT6\xb6\x17\xab\xfb\x82\x98\x83\xcd\xd6\x07g\xd1e\xeaEM\xd8\x17&Fp1nodeT+N\xb0s\x19\xbf\xa0w\xa4\n
+  s>     /\x04\x916Y\xae\xf0\xdaB\xdaFp2nodeT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00
+  s>     \r\n
+  received frame(size=452; request=1; stream=2; streamflags=; type=command-response; flags=)
+  s>     8\r\n
+  s>     \x00\x00\x00\x01\x00\x02\x002
+  s>     \r\n
+  s>     0\r\n
+  s>     \r\n
+  received frame(size=0; request=1; stream=2; streamflags=; type=command-response; flags=eos)
+  response: [{b'status': b'ok'}, {b'linknode': b'\xfd\x8dO6\xd3@\xdb\x7f\xa4Y\x10\x078k\xac\x0c\xc1\xb2#\xd0', b'node': b'+N\xb0s\x19\xbf\xa0w\xa4\n/\x04\x916Y\xae\xf0\xdaB\xda', b'p1node': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', b'p2node': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'}, {b'linknode': b'\x04\x03\xe9K\x8c\xe4e\xa1\xf6\x99;\xb1\xe4`{\xbe\x17`V\xa7', b'node': b'\x9a8\x12)\x97\xb3\xac\x97\xbe*\x9a\xa2\xe5V\x83\x83A\xfd\xf2\xcc', b'p1node': b'+N\xb0s\x19\xbf\xa0w\xa4\n/\x04\x916Y\xae\xf0\xdaB\xda', b'p2node': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'}, {b'linknode': b'\x0e\xb5\x06\x83\x84\x08E\xf3\xa7R\x8a}\xf3+l\xe2\x1c1J\x19', b'node': b'\xc2\xa2\x05\xc8\xb2\xad\xe2J\xf2`b\xe5<\xd5\xbc8\x01\xd6`\xda', b'p1node': b'\x9a8\x12)\x97\xb3\xac\x97\xbe*\x9a\xa2\xe5V\x83\x83A\xfd\xf2\xcc', b'p2node': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'}, {b'linknode': b'\x15_\xcd\xfdBs\xeb\xeb\\.T\xd1\xc8q\x1f\xfd\xc2\x993\xab', b'node': b'6\xb6\x17\xab\xfb\x82\x98\x83\xcd\xd6\x07g\xd1e\xeaEM\xd8\x17&', b'p1node': b'+N\xb0s\x19\xbf\xa0w\xa4\n/\x04\x916Y\xae\xf0\xdaB\xda', b'p2node': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'}]
+
+Requesting history with a pinned head limits results to that head
+
+  $ sendhttpv2peer << EOF
+  > command filehistory
+  >    path a
+  >    filenodeheads eval:[b'+N\xb0s\x19\xbf\xa0w\xa4\n/\x04\x916Y\xae\xf0\xdaB\xda']
+  >    filenodebases eval:[]
+  > EOF
+  creating http peer for wire protocol version 2
+  sending filehistory command
+  s>     POST /api/exp-http-v2-0001/ro/filehistory HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0005\r\n
+  s>     content-type: application/mercurial-exp-framing-0005\r\n
+  s>     content-length: 90\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     user-agent: Mercurial debugwireproto\r\n
+  s>     \r\n
+  s>     R\x00\x00\x01\x00\x01\x01\x11\xa2Dargs\xa3DpathAaMfilenodebases\x80Mfilenodeheads\x81T+N\xb0s\x19\xbf\xa0w\xa4\n
+  s>     /\x04\x916Y\xae\xf0\xdaB\xdaDnameKfilehistory
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: application/mercurial-exp-framing-0005\r\n
+  s>     Transfer-Encoding: chunked\r\n
+  s>     \r\n
+  s>     13\r\n
+  s>     \x0b\x00\x00\x01\x00\x02\x011
+  s>     \xa1FstatusBok
+  s>     \r\n
+  received frame(size=11; request=1; stream=2; streamflags=stream-begin; type=command-response; flags=continuation)
+  s>     79\r\n
+  s>     q\x00\x00\x01\x00\x02\x000
+  s>     \xa4HlinknodeT\xfd\x8dO6\xd3@\xdb\x7f\xa4Y\x10\x078k\xac\x0c\xc1\xb2#\xd0DnodeT+N\xb0s\x19\xbf\xa0w\xa4\n
+  s>     /\x04\x916Y\xae\xf0\xdaB\xdaFp1nodeT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00Fp2nodeT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00
+  s>     \r\n
+  received frame(size=113; request=1; stream=2; streamflags=; type=command-response; flags=)
+  s>     8\r\n
+  s>     \x00\x00\x00\x01\x00\x02\x002
+  s>     \r\n
+  s>     0\r\n
+  s>     \r\n
+  received frame(size=0; request=1; stream=2; streamflags=; type=command-response; flags=eos)
+  response: [{b'status': b'ok'}, {b'linknode': b'\xfd\x8dO6\xd3@\xdb\x7f\xa4Y\x10\x078k\xac\x0c\xc1\xb2#\xd0', b'node': b'+N\xb0s\x19\xbf\xa0w\xa4\n/\x04\x916Y\xae\xf0\xdaB\xda', b'p1node': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', b'p2node': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'}]
+
+Requesting history with bases does not send history for those bases and ancestors
+
+  $ sendhttpv2peer << EOF
+  > command filehistory
+  >     path a
+  >     filenodeheads eval:[]
+  >     filenodebases eval:[b'\xc2\xa2\x05\xc8\xb2\xad\xe2J\xf2\x60b\xe5<\xd5\xbc8\x01\xd6\x60\xda']
+  > EOF
+  creating http peer for wire protocol version 2
+  sending filehistory command
+  s>     POST /api/exp-http-v2-0001/ro/filehistory HTTP/1.1\r\n
+  s>     Accept-Encoding: identity\r\n
+  s>     accept: application/mercurial-exp-framing-0005\r\n
+  s>     content-type: application/mercurial-exp-framing-0005\r\n
+  s>     content-length: 90\r\n
+  s>     host: $LOCALIP:$HGPORT\r\n (glob)
+  s>     user-agent: Mercurial debugwireproto\r\n
+  s>     \r\n
+  s>     R\x00\x00\x01\x00\x01\x01\x11\xa2Dargs\xa3DpathAaMfilenodebases\x81T\xc2\xa2\x05\xc8\xb2\xad\xe2J\xf2`b\xe5<\xd5\xbc8\x01\xd6`\xdaMfilenodeheads\x80DnameKfilehistory
+  s> makefile('rb', None)
+  s>     HTTP/1.1 200 OK\r\n
+  s>     Server: testing stub value\r\n
+  s>     Date: $HTTP_DATE$\r\n
+  s>     Content-Type: application/mercurial-exp-framing-0005\r\n
+  s>     Transfer-Encoding: chunked\r\n
+  s>     \r\n
+  s>     13\r\n
+  s>     \x0b\x00\x00\x01\x00\x02\x011
+  s>     \xa1FstatusBok
+  s>     \r\n
+  received frame(size=11; request=1; stream=2; streamflags=stream-begin; type=command-response; flags=continuation)
+  s>     79\r\n
+  s>     q\x00\x00\x01\x00\x02\x000
+  s>     \xa4HlinknodeT\x15_\xcd\xfdBs\xeb\xeb\\.T\xd1\xc8q\x1f\xfd\xc2\x993\xabDnodeT6\xb6\x17\xab\xfb\x82\x98\x83\xcd\xd6\x07g\xd1e\xeaEM\xd8\x17&Fp1nodeT+N\xb0s\x19\xbf\xa0w\xa4\n
+  s>     /\x04\x916Y\xae\xf0\xdaB\xdaFp2nodeT\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00
+  s>     \r\n
+  received frame(size=113; request=1; stream=2; streamflags=; type=command-response; flags=)
+  s>     8\r\n
+  s>     \x00\x00\x00\x01\x00\x02\x002
+  s>     \r\n
+  s>     0\r\n
+  s>     \r\n
+  received frame(size=0; request=1; stream=2; streamflags=; type=command-response; flags=eos)
+  response: [{b'status': b'ok'}, {b'linknode': b'\x15_\xcd\xfdBs\xeb\xeb\\.T\xd1\xc8q\x1f\xfd\xc2\x993\xab', b'node': b'6\xb6\x17\xab\xfb\x82\x98\x83\xcd\xd6\x07g\xd1e\xeaEM\xd8\x17&', b'p1node': b'+N\xb0s\x19\xbf\xa0w\xa4\n/\x04\x916Y\xae\xf0\xdaB\xda', b'p2node': b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'}]
+
+  $ cat error.log
diff --git a/tests/test-wireproto-command-capabilities.t b/tests/test-wireproto-command-capabilities.t
--- a/tests/test-wireproto-command-capabilities.t
+++ b/tests/test-wireproto-command-capabilities.t
@@ -202,8 +202,8 @@ 
   s>     Content-Type: application/mercurial-cbor\r\n
   s>     Content-Length: *\r\n (glob)
   s>     \r\n
-  s>     \xa3Dapis\xa1Pexp-http-v2-0001\xa4Hcommands\xa8Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullLrawstorefile\xa2Dargs\xa1DwhatIchangelogKpermissions\x81DpullKcompression\x81\xa1DnameDzlibNrawrepoformats\x82LgeneraldeltaHrevlogv1Qframingmediatypes\x81X&application/mercurial-exp-framing-0005GapibaseDapi/Nv1capabilitiesY\x01\xc5batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
-  cbor> {b'apibase': b'api/', b'apis': {b'exp-http-v2-0001': {b'commands': {b'branchmap': {b'args': {}, b'permissions': [b'pull']}, b'capabilities': {b'args': {}, b'permissions': [b'pull']}, b'heads': {b'args': {b'publiconly': False}, b'permissions': [b'pull']}, b'known': {b'args': {b'nodes': [b'deadbeef']}, b'permissions': [b'pull']}, b'listkeys': {b'args': {b'namespace': b'ns'}, b'permissions': [b'pull']}, b'lookup': {b'args': {b'key': b'foo'}, b'permissions': [b'pull']}, b'pushkey': {b'args': {b'key': b'key', b'namespace': b'ns', b'new': b'new', b'old': b'old'}, b'permissions': [b'push']}, b'rawstorefile': {b'args': {b'what': b'changelog'}, b'permissions': [b'pull']}}, b'compression': [{b'name': b'zlib'}], b'framingmediatypes': [b'application/mercurial-exp-framing-0005'], b'rawrepoformats': [b'generaldelta', b'revlogv1']}}, b'v1capabilities': b'batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash'}
+  s>     \xa3Dapis\xa1Pexp-http-v2-0001\xa4Hcommands\xa9Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullKfilehistory\xa2Dargs\xa3DpathLpath/to/fileMfilenodebases\x81HdeadbeefMfilenodeheads\x81HdeadbeefKpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullLrawstorefile\xa2Dargs\xa1DwhatIchangelogKpermissions\x81DpullKcompression\x81\xa1DnameDzlibNrawrepoformats\x82LgeneraldeltaHrevlogv1Qframingmediatypes\x81X&application/mercurial-exp-framing-0005GapibaseDapi/Nv1capabilitiesY\x01\xc5batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
+  cbor> {b'apibase': b'api/', b'apis': {b'exp-http-v2-0001': {b'commands': {b'branchmap': {b'args': {}, b'permissions': [b'pull']}, b'capabilities': {b'args': {}, b'permissions': [b'pull']}, b'filehistory': {b'args': {b'filenodebases': [b'deadbeef'], b'filenodeheads': [b'deadbeef'], b'path': b'path/to/file'}, b'permissions': [b'pull']}, b'heads': {b'args': {b'publiconly': False}, b'permissions': [b'pull']}, b'known': {b'args': {b'nodes': [b'deadbeef']}, b'permissions': [b'pull']}, b'listkeys': {b'args': {b'namespace': b'ns'}, b'permissions': [b'pull']}, b'lookup': {b'args': {b'key': b'foo'}, b'permissions': [b'pull']}, b'pushkey': {b'args': {b'key': b'key', b'namespace': b'ns', b'new': b'new', b'old': b'old'}, b'permissions': [b'push']}, b'rawstorefile': {b'args': {b'what': b'changelog'}, b'permissions': [b'pull']}}, b'compression': [{b'name': b'zlib'}], b'framingmediatypes': [b'application/mercurial-exp-framing-0005'], b'rawrepoformats': [b'generaldelta', b'revlogv1']}}, b'v1capabilities': b'batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash'}
 
 capabilities command returns expected info
 
@@ -227,7 +227,7 @@ 
   s>     Content-Type: application/mercurial-cbor\r\n
   s>     Content-Length: *\r\n (glob)
   s>     \r\n
-  s>     \xa3Dapis\xa1Pexp-http-v2-0001\xa4Hcommands\xa8Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullLrawstorefile\xa2Dargs\xa1DwhatIchangelogKpermissions\x81DpullKcompression\x81\xa1DnameDzlibNrawrepoformats\x82LgeneraldeltaHrevlogv1Qframingmediatypes\x81X&application/mercurial-exp-framing-0005GapibaseDapi/Nv1capabilitiesY\x01\xc5batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
+  s>     \xa3Dapis\xa1Pexp-http-v2-0001\xa4Hcommands\xa9Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullKfilehistory\xa2Dargs\xa3DpathLpath/to/fileMfilenodebases\x81HdeadbeefMfilenodeheads\x81HdeadbeefKpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullLrawstorefile\xa2Dargs\xa1DwhatIchangelogKpermissions\x81DpullKcompression\x81\xa1DnameDzlibNrawrepoformats\x82LgeneraldeltaHrevlogv1Qframingmediatypes\x81X&application/mercurial-exp-framing-0005GapibaseDapi/Nv1capabilitiesY\x01\xc5batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
   sending capabilities command
   s>     POST /api/exp-http-v2-0001/ro/capabilities HTTP/1.1\r\n
   s>     Accept-Encoding: identity\r\n
@@ -245,13 +245,13 @@ 
   s>     Content-Type: application/mercurial-exp-framing-0005\r\n
   s>     Transfer-Encoding: chunked\r\n
   s>     \r\n
-  s>     20c\r\n
-  s>     \x04\x02\x00\x01\x00\x02\x012
-  s>     \xa1FstatusBok\xa4Hcommands\xa8Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullLrawstorefile\xa2Dargs\xa1DwhatIchangelogKpermissions\x81DpullKcompression\x81\xa1DnameDzlibNrawrepoformats\x82LgeneraldeltaHrevlogv1Qframingmediatypes\x81X&application/mercurial-exp-framing-0005
+  s>     273\r\n
+  s>     k\x02\x00\x01\x00\x02\x012
+  s>     \xa1FstatusBok\xa4Hcommands\xa9Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullKfilehistory\xa2Dargs\xa3DpathLpath/to/fileMfilenodebases\x81HdeadbeefMfilenodeheads\x81HdeadbeefKpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullLrawstorefile\xa2Dargs\xa1DwhatIchangelogKpermissions\x81DpullKcompression\x81\xa1DnameDzlibNrawrepoformats\x82LgeneraldeltaHrevlogv1Qframingmediatypes\x81X&application/mercurial-exp-framing-0005
   s>     \r\n
-  received frame(size=516; request=1; stream=2; streamflags=stream-begin; type=command-response; flags=eos)
+  received frame(size=619; request=1; stream=2; streamflags=stream-begin; type=command-response; flags=eos)
   s>     0\r\n
   s>     \r\n
-  response: [{b'status': b'ok'}, {b'commands': {b'branchmap': {b'args': {}, b'permissions': [b'pull']}, b'capabilities': {b'args': {}, b'permissions': [b'pull']}, b'heads': {b'args': {b'publiconly': False}, b'permissions': [b'pull']}, b'known': {b'args': {b'nodes': [b'deadbeef']}, b'permissions': [b'pull']}, b'listkeys': {b'args': {b'namespace': b'ns'}, b'permissions': [b'pull']}, b'lookup': {b'args': {b'key': b'foo'}, b'permissions': [b'pull']}, b'pushkey': {b'args': {b'key': b'key', b'namespace': b'ns', b'new': b'new', b'old': b'old'}, b'permissions': [b'push']}, b'rawstorefile': {b'args': {b'what': b'changelog'}, b'permissions': [b'pull']}}, b'compression': [{b'name': b'zlib'}], b'framingmediatypes': [b'application/mercurial-exp-framing-0005'], b'rawrepoformats': [b'generaldelta', b'revlogv1']}]
+  response: [{b'status': b'ok'}, {b'commands': {b'branchmap': {b'args': {}, b'permissions': [b'pull']}, b'capabilities': {b'args': {}, b'permissions': [b'pull']}, b'filehistory': {b'args': {b'filenodebases': [b'deadbeef'], b'filenodeheads': [b'deadbeef'], b'path': b'path/to/file'}, b'permissions': [b'pull']}, b'heads': {b'args': {b'publiconly': False}, b'permissions': [b'pull']}, b'known': {b'args': {b'nodes': [b'deadbeef']}, b'permissions': [b'pull']}, b'listkeys': {b'args': {b'namespace': b'ns'}, b'permissions': [b'pull']}, b'lookup': {b'args': {b'key': b'foo'}, b'permissions': [b'pull']}, b'pushkey': {b'args': {b'key': b'key', b'namespace': b'ns', b'new': b'new', b'old': b'old'}, b'permissions': [b'push']}, b'rawstorefile': {b'args': {b'what': b'changelog'}, b'permissions': [b'pull']}}, b'compression': [{b'name': b'zlib'}], b'framingmediatypes': [b'application/mercurial-exp-framing-0005'], b'rawrepoformats': [b'generaldelta', b'revlogv1']}]
 
   $ cat error.log
diff --git a/tests/test-http-protocol.t b/tests/test-http-protocol.t
--- a/tests/test-http-protocol.t
+++ b/tests/test-http-protocol.t
@@ -305,7 +305,7 @@ 
   s>     Content-Type: application/mercurial-cbor\r\n
   s>     Content-Length: *\r\n (glob)
   s>     \r\n
-  s>     \xa3Dapis\xa1Pexp-http-v2-0001\xa4Hcommands\xa8Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullLrawstorefile\xa2Dargs\xa1DwhatIchangelogKpermissions\x81DpullKcompression\x81\xa1DnameDzlibNrawrepoformats\x82LgeneraldeltaHrevlogv1Qframingmediatypes\x81X&application/mercurial-exp-framing-0005GapibaseDapi/Nv1capabilitiesY\x01\xc5batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
+  s>     \xa3Dapis\xa1Pexp-http-v2-0001\xa4Hcommands\xa9Eheads\xa2Dargs\xa1Jpubliconly\xf4Kpermissions\x81DpullEknown\xa2Dargs\xa1Enodes\x81HdeadbeefKpermissions\x81DpullFlookup\xa2Dargs\xa1CkeyCfooKpermissions\x81DpullGpushkey\xa2Dargs\xa4CkeyCkeyCnewCnewColdColdInamespaceBnsKpermissions\x81DpushHlistkeys\xa2Dargs\xa1InamespaceBnsKpermissions\x81DpullIbranchmap\xa2Dargs\xa0Kpermissions\x81DpullKfilehistory\xa2Dargs\xa3DpathLpath/to/fileMfilenodebases\x81HdeadbeefMfilenodeheads\x81HdeadbeefKpermissions\x81DpullLcapabilities\xa2Dargs\xa0Kpermissions\x81DpullLrawstorefile\xa2Dargs\xa1DwhatIchangelogKpermissions\x81DpullKcompression\x81\xa1DnameDzlibNrawrepoformats\x82LgeneraldeltaHrevlogv1Qframingmediatypes\x81X&application/mercurial-exp-framing-0005GapibaseDapi/Nv1capabilitiesY\x01\xc5batch branchmap $USUAL_BUNDLE2_CAPS_SERVER$ changegroupsubset compression=$BUNDLE2_COMPRESSIONS$ getbundle httpheader=1024 httpmediatype=0.1rx,0.1tx,0.2tx known lookup pushkey streamreqs=generaldelta,revlogv1 unbundle=HG10GZ,HG10BZ,HG10UN unbundlehash
   sending heads command
   s>     POST /api/exp-http-v2-0001/ro/heads HTTP/1.1\r\n
   s>     Accept-Encoding: identity\r\n
diff --git a/mercurial/wireprotov2server.py b/mercurial/wireprotov2server.py
--- a/mercurial/wireprotov2server.py
+++ b/mercurial/wireprotov2server.py
@@ -13,6 +13,7 @@ 
     cbor,
 )
 from . import (
+    ancestor,
     encoding,
     error,
     pycompat,
@@ -471,6 +472,72 @@ 
 
     return wireprototypes.cborresponse(caps)
 
+@wireprotocommand('filehistory',
+                  args={
+                      'path': b'path/to/file',
+                      'filenodeheads': [b'deadbeef'],
+                      'filenodebases': [b'deadbeef'],
+                  },
+                  permission='pull')
+def filehistory(repo, proto, path, filenodeheads, filenodebases):
+    # This seems to work even if the file doesn't exist. So catch
+    # "empty" files and return an error.
+    fl = repo.file(path)
+
+    if not len(fl):
+        return wireprototypes.v2errorresponse('unknown file: %s', (path))
+
+    clnode = repo.changelog.node
+    getnode = fl.node
+    parents = fl.parents
+    linkrev = fl.linkrev
+    iscensored = fl.iscensored
+
+    try:
+        headrevs = [fl.rev(n) for n in filenodeheads]
+        stoprevs = [fl.rev(n) for n in filenodebases]
+    except error.LookupError as e:
+        return wireprototypes.v2errorresponse('failed to resolve node: %s',
+                                              (e.name,))
+
+    if not headrevs:
+        headrevs = fl.headrevs()
+
+    clrevs = {r for r in repo.changelog}
+    linkrevs = [(r, linkrev(r)) for r in fl]
+    linknodes = {getnode(r): clnode(lr) for r, lr in linkrevs if lr in clrevs}
+
+    def senddata():
+        ima = ancestor.incrementalmissingancestors(fl.parentrevs, stoprevs)
+
+        for rev in ima.missingancestors(headrevs):
+            node = getnode(rev)
+            p1node, p2node = parents(node)
+            renamed = fl.renamed(node)
+
+            res = {
+                b'node': node,
+                b'p1node': p1node,
+                b'p2node': p2node,
+            }
+
+            # TODO what is the most efficient way to resolve linknodes if the
+            # original changeset is hidden?
+            linknode = linknodes.get(node)
+            if linknode:
+                res[b'linknode'] = linknode
+
+            if iscensored(rev):
+                res[b'censored'] = True
+
+            if renamed:
+                res[b'copyinfo'] = renamed
+
+            for chunk in cborutil.streamencodemap(res):
+                yield chunk
+
+    return wireprototypes.v2streamingresponse(senddata())
+
 @wireprotocommand('heads',
                   args={
                       'publiconly': False,
diff --git a/mercurial/help/internals/wireprotocol.txt b/mercurial/help/internals/wireprotocol.txt
--- a/mercurial/help/internals/wireprotocol.txt
+++ b/mercurial/help/internals/wireprotocol.txt
@@ -1831,6 +1831,52 @@ 
    requirements can be used to determine whether a client can read a
    *raw* copy of file data available.
 
+filehistory
+-----------
+
+Obtain history for a specific tracked path.
+
+The command accepts the following arguments:
+
+path
+   (bytestring) The name of the tracked path.
+
+filenodeheads
+   (array of bytestrings) File nodes where ancestry traversal will
+   initiate. If the array is empty, all DAG heads will be considered.
+
+filenodebases
+   (array of bytestrings) File nodes where ancestry traversal will stop.
+
+The response is a series of CBOR maps (not part of any outer container)
+containing file history records. The records are emitted in topological
+order (parents are emitted before their children). Nodes in
+``filenodebases`` are never included. Nodes in ``filenodesheads`` are
+included, unless they are members of ``filenodebases``.
+
+Each history map contains the following bytestring keys:
+
+node
+   (bytestring) Node for this file revision.
+
+p1node
+   (bytestring) Node for 1st parent of this revision.
+
+p2node
+   (bytestring) Node for 2nd parent of this revision.
+
+linknode
+   (bytestring) Node of changeset this revision is associated with.
+
+copyinfo (optional)
+   (Array of bytestrings) Contained the file path and file node value
+   from which this revision was copied.
+
+censored (optional)
+   (True) If present, the value is always True and indicates that the
+   content for this revision has been *censored* and is no longer
+   available. Attempts to fetch the revision data will fail.
+
 heads
 -----