diff mercurial/archival.py @ 51834:3b8d92f71d92

archive: defer opening the output until a file is matched Before, if no file is matched, an error is thrown, but the archive is created anyway. When using hgweb, an error 500 is returned as the response body already exists when the error is seen. Afterwards, the archive is created before the first match is emitted. If no match is found, no archive is created. This is more consistent behavior as an empty archive is not a representable in all output formats, e.g. tar archives.
author Joerg Sonnenberger <joerg@bec.de>
date Wed, 15 Nov 2023 22:11:34 +0100
parents 55d45d0de4e7
children b60f25f00e94
line wrap: on
line diff
--- a/mercurial/archival.py	Thu Sep 05 13:37:24 2024 +0200
+++ b/mercurial/archival.py	Wed Nov 15 22:11:34 2023 +0100
@@ -293,8 +293,9 @@
 ):
     """create archive of repo as it was at node.
 
-    dest can be name of directory, name of archive file, or file
-    object to write archive to.
+    dest can be name of directory, name of archive file, a callable, or file
+    object to write archive to. If it is a callable, it will called to open
+    the actual file object before the first archive member is written.
 
     kind is type of archive to create.
 
@@ -316,7 +317,37 @@
     else:
         prefix = tidyprefix(dest, kind, prefix)
 
+    archiver = None
+    ctx = repo[node]
+
+    def opencallback():
+        """Return the archiver instance, creating it if necessary.
+
+        This function is called when the first actual entry is created.
+        It may be called multiple times from different layers.
+        When serving the archive via hgweb, no errors should happen after
+        this point.
+        """
+        nonlocal archiver
+        if archiver is None:
+            if callable(dest):
+                output = dest()
+            else:
+                output = dest
+            archiver = archivers[kind](output, mtime or ctx.date()[0])
+            assert archiver is not None
+
+            if repo.ui.configbool(b"ui", b"archivemeta"):
+                metaname = b'.hg_archival.txt'
+                if match(metaname):
+                    write(metaname, 0o644, False, lambda: buildmetadata(ctx))
+        return archiver
+
     def write(name, mode, islink, getdata):
+        if archiver is None:
+            opencallback()
+        assert archiver is not None, "archive should be opened by now"
+
         data = getdata()
         if decode:
             data = repo.wwritedata(name, data)
@@ -325,17 +356,9 @@
     if kind not in archivers:
         raise error.Abort(_(b"unknown archive type '%s'") % kind)
 
-    ctx = repo[node]
-    archiver = archivers[kind](dest, mtime or ctx.date()[0])
-
     if not match:
         match = scmutil.matchall(repo)
 
-    if repo.ui.configbool(b"ui", b"archivemeta"):
-        name = b'.hg_archival.txt'
-        if match(name):
-            write(name, 0o644, False, lambda: buildmetadata(ctx))
-
     files = list(ctx.manifest().walk(match))
     total = len(files)
     if total:
@@ -358,10 +381,11 @@
             sub = ctx.workingsub(subpath)
             submatch = matchmod.subdirmatcher(subpath, match)
             subprefix = prefix + subpath + b'/'
-            total += sub.archive(archiver, subprefix, submatch, decode)
+            total += sub.archive(opencallback, subprefix, submatch, decode)
 
     if total == 0:
         raise error.Abort(_(b'no files match the archive pattern'))
 
+    assert archiver is not None, "archive should have been opened before"
     archiver.done()
     return total