diff --git a/mercurial/hgweb/hgwebdir_mod.py b/mercurial/hgweb/hgwebdir_mod.py
--- a/mercurial/hgweb/hgwebdir_mod.py
+++ b/mercurial/hgweb/hgwebdir_mod.py
@@ -293,8 +293,14 @@
                     # variable.
                     # TODO this is kind of hacky and we should have a better
                     # way of doing this than with REPO_NAME side-effects.
+                    previousdata = (
+                        wsgireq.req.postformlist,
+                        wsgireq.req.postformdict,
+                        wsgireq.req.params,
+                    )
                     wsgireq.req = requestmod.parserequestfromenv(
-                        wsgireq.env, wsgireq.req.bodyfh)
+                        wsgireq.env, wsgireq.req.bodyfh,
+                        previousformdata=previousdata)
                     try:
                         # ensure caller gets private copy of ui
                         repo = hg.repository(self.ui.copy(), real)
diff --git a/mercurial/hgweb/request.py b/mercurial/hgweb/request.py
--- a/mercurial/hgweb/request.py
+++ b/mercurial/hgweb/request.py
@@ -96,8 +96,15 @@
     headers = attr.ib()
     # Request body input stream.
     bodyfh = attr.ib()
+    # Like ``querystringlist`` and ``querystringdict`` but for form data
+    # submitted on POST requests decoded from well-known content types.
+    postformlist = attr.ib()
+    postformdict = attr.ib()
 
-def parserequestfromenv(env, bodyfh):
+    # All "form" parameters. A combination of query string and POST form data.
+    params = attr.ib()
+
+def parserequestfromenv(env, bodyfh, previousformdata=None):
     """Parse URL components from environment variables.
 
     WSGI defines request attributes via environment variables. This function
@@ -220,6 +227,49 @@
     #if 'Content-Length' in headers:
     #    bodyfh = util.cappedreader(bodyfh, int(headers['Content-Length']))
 
+    # Form data is kinda wonky. It can come from request bodies, which we can
+    # only read once. Since hgwebdir may construct a new parsedrequest, we
+    # allow existing form data to be passed in to this function. That's kinda
+    # hacky.
+    if previousformdata is None:
+        if env['REQUEST_METHOD'] == 'POST':
+            # This is based on cgi.parse(), but without the hacky parts (like
+            # merging QUERY_STRING and setting QUERY_STRING as a side-effect).
+            ct, params = cgi.parse_header(env.get('CONTENT_TYPE', ''))
+            if ct == 'multipart/form-data':
+                # We don't have a way to preserve order. So we normalize to a
+                # list for consistency with x-www-form-urlencoded.
+                postformlist = []
+                for k, l in cgi.parse_multipart(bodyfh, params).iteritems():
+                    for v in l:
+                        postformlist.append((k, v))
+            elif ct == 'application/x-www-form-urlencoded':
+                cl = int(headers['Content-Length'])
+                postformlist = util.urlreq.parseqsl(bodyfh.read(cl),
+                                                    keep_blank_values=True)
+            else:
+                postformlist = []
+
+            postformdict = {}
+            for k, v in postformdict:
+                if k in postformdict:
+                    postformdict[k].append(v)
+                else:
+                    postformdict[k] = [v]
+        else:
+            postformlist = []
+            postformdict = {}
+
+        # Now that we have the raw post data. Merge in query string data to
+        # provide a unified interface.
+        formdict = {k: list(v) for k, v in postformdict.iteritems()}
+        for k, l in querystringdict.iteritems():
+            if k not in formdict:
+                formdict[k] = []
+            formdict[k].extend(l)
+    else:
+        postformlist, postformdict, formdict = previousformdata
+
     return parsedrequest(method=env['REQUEST_METHOD'],
                          url=fullurl, baseurl=baseurl,
                          advertisedurl=advertisedfullurl,
@@ -231,7 +281,9 @@
                          querystringlist=querystringlist,
                          querystringdict=querystringdict,
                          headers=headers,
-                         bodyfh=bodyfh)
+                         bodyfh=bodyfh,
+                         postformlist=postformlist, postformdict=postformdict,
+                         params=formdict)
 
 class wsgirequest(object):
     """Higher-level API for a WSGI request.
@@ -258,15 +310,12 @@
         self.multiprocess = wsgienv[r'wsgi.multiprocess']
         self.run_once = wsgienv[r'wsgi.run_once']
         self.env = wsgienv
-        self.form = normalize(cgi.parse(inp,
-                                        self.env,
-                                        keep_blank_values=1))
+        self.req = parserequestfromenv(wsgienv, inp)
+        self.form = normalize(self.req.params)
         self._start_response = start_response
         self.server_write = None
         self.headers = []
 
-        self.req = parserequestfromenv(wsgienv, inp)
-
     def respond(self, status, type, filename=None, body=None):
         if not isinstance(type, str):
             type = pycompat.sysstr(type)