archive.py (5585B)
1 #!/usr/bin/env python3 2 3 # This file has been placed in the public domain. 4 5 """Create a source archive containing this repository and its submodules.""" 6 7 from __future__ import annotations 8 9 import argparse 10 import io 11 import json 12 import os 13 from pathlib import Path 14 import subprocess 15 import sys 16 import tarfile 17 18 19 def run_git(repo: Path, *args: str, input_data: bytes | None = None) -> bytes: 20 try: 21 return subprocess.run( 22 ["git", *args], 23 cwd=repo, 24 input=input_data, 25 check=True, 26 stdout=subprocess.PIPE, 27 ).stdout 28 except subprocess.CalledProcessError as exc: 29 command = " ".join(("git", *args)) 30 raise RuntimeError(f"'{command}' failed in {repo}") from exc 31 32 33 def tracked_paths(repo: Path) -> list[Path]: 34 output = run_git(repo, "ls-files", "-z", "--cached") 35 return [ 36 Path(os.fsdecode(item)) for item in output.rstrip(b"\0").split(b"\0") if item 37 ] 38 39 40 def submodule_paths(repo: Path) -> set[Path]: 41 gitmodules = repo / ".gitmodules" 42 if not gitmodules.exists(): 43 return set() 44 result = subprocess.run( 45 [ 46 "git", 47 "config", 48 "-f", 49 ".gitmodules", 50 "--get-regexp", 51 r"^submodule\..*\.path$", 52 ], 53 cwd=repo, 54 check=False, 55 stdout=subprocess.PIPE, 56 text=True, 57 ) 58 if result.returncode not in (0, 1): 59 raise RuntimeError(f"could not read submodules from {gitmodules}") 60 return {Path(line.split(maxsplit=1)[1]) for line in result.stdout.splitlines()} 61 62 63 def export_ignored(repo: Path, paths: list[Path]) -> set[Path]: 64 if not paths: 65 return set() 66 query = b"\0".join(os.fsencode(path) for path in paths) + b"\0" 67 output = run_git( 68 repo, "check-attr", "-z", "--stdin", "export-ignore", input_data=query 69 ) 70 fields = output.rstrip(b"\0").split(b"\0") 71 ignored: set[Path] = set() 72 for index in range(0, len(fields), 3): 73 if fields[index + 2] == b"set": 74 ignored.add(Path(os.fsdecode(fields[index]))) 75 return ignored 76 77 78 def repository_entries(repo: Path) -> list[tuple[Path, Path]]: 79 """Return (filesystem path, archive-relative path) pairs for one repository.""" 80 submodules = submodule_paths(repo) 81 candidates: list[tuple[Path, Path]] = [] 82 83 for relative in tracked_paths(repo): 84 if relative not in submodules: 85 candidates.append((repo / relative, relative)) 86 87 for submodule in sorted(submodules): 88 submodule_dir = repo / submodule 89 if not (submodule_dir / ".git").exists(): 90 raise RuntimeError( 91 f"submodule '{submodule}' is not initialized; run './bootstrap' first" 92 ) 93 for source, relative in repository_entries(submodule_dir): 94 candidates.append((source, submodule / relative)) 95 96 ignored = export_ignored(repo, [relative for _, relative in candidates]) 97 return [ 98 (source, relative) for source, relative in candidates if relative not in ignored 99 ] 100 101 102 def archive_prefix(output: Path) -> Path: 103 name = output.name 104 for suffix in (".tar.gz", ".tgz"): 105 if name.endswith(suffix): 106 prefix = name[: -len(suffix)] 107 if prefix: 108 return Path(prefix) 109 break 110 raise ValueError("output file must end in .tar.gz or .tgz") 111 112 113 def parse_args() -> argparse.Namespace: 114 parser = argparse.ArgumentParser(description=__doc__) 115 parser.add_argument( 116 "--include", 117 action="append", 118 default=[], 119 metavar="FILE", 120 help="include an untracked or export-ignored file (may be repeated)", 121 ) 122 parser.add_argument("output", type=Path, help="output .tar.gz file") 123 return parser.parse_args() 124 125 126 def main() -> int: 127 args = parse_args() 128 root = Path(run_git(Path.cwd(), "rev-parse", "--show-toplevel").decode().strip()) 129 output = args.output.resolve() 130 prefix = archive_prefix(output) 131 entries = repository_entries(root) 132 build_info = subprocess.run( 133 ["node", str(root / "build-system/version-cli.mjs"), "--json", str(root)], 134 check=True, 135 stdout=subprocess.PIPE, 136 ).stdout 137 version_metadata = json.loads(build_info) 138 139 included: list[tuple[Path, Path]] = [] 140 for name in args.include: 141 relative = Path(name) 142 if relative.is_absolute() or ".." in relative.parts: 143 raise ValueError( 144 f"included path must be relative to the repository: {name}" 145 ) 146 source = (root / relative).absolute() 147 if not source.is_file(): 148 raise FileNotFoundError(f"included file does not exist: {name}") 149 included.append((source, relative)) 150 151 with tarfile.open(output, "w:gz") as archive: 152 for source, relative in [*included, *entries]: 153 if relative == Path("build-info.json"): 154 continue 155 archive.add(source, arcname=prefix / relative, recursive=False) 156 metadata = tarfile.TarInfo(str(prefix / "build-info.json")) 157 metadata.size = len(build_info) 158 metadata.mode = 0o644 159 metadata.mtime = int( 160 run_git(root, "show", "-s", "--format=%ct", version_metadata["gitHash"]) 161 ) 162 archive.addfile(metadata, io.BytesIO(build_info)) 163 164 print(f"created {output}") 165 return 0 166 167 168 if __name__ == "__main__": 169 try: 170 sys.exit(main()) 171 except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as exc: 172 print(f"archive: {exc}", file=sys.stderr) 173 sys.exit(1)