Created
January 15, 2026 14:07
-
-
Save thegamecracks/dff23360df8036217218582b0cdc361d to your computer and use it in GitHub Desktop.
Create a .zip archive of a .NET solution directory with build artifacts omitted
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Create a .zip archive of a .NET solution directory with build artifacts omitted. | |
| If the solution directory is tracked in a Git repository that has a .gitignore to | |
| exclude .NET build artifacts, consider `git archive <branch> -o <filename>` instead. | |
| """ | |
| import argparse | |
| import fnmatch | |
| import hashlib | |
| import sys | |
| from pathlib import Path, PurePosixPath | |
| from typing import IO, Iterator | |
| from zipfile import ZIP_DEFLATED, ZipFile | |
| IGNORE_GLOBAL_PATTERNS = (".*", "*.doc", "*.docx", "*.docx#", "*.pdf") | |
| IGNORE_PROJECT_PATTERNS = ("bin", "obj") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| collision_group = parser.add_mutually_exclusive_group() | |
| collision_group.add_argument( | |
| "-c", | |
| "--check", | |
| action="store_true", | |
| help="If dest exists, compare contents with current files", | |
| ) | |
| collision_group.add_argument( | |
| "-f", | |
| "--force", | |
| action="store_true", | |
| help="Write archive even if dest exists", | |
| ) | |
| parser.add_argument("solution", help="The solution directory to archive", type=Path) | |
| parser.add_argument( | |
| "dest", | |
| help="Where to write the archive (defaults to name of solution directory)", | |
| nargs="?", | |
| type=Path, | |
| ) | |
| args = parser.parse_args() | |
| check: bool = args.check | |
| force: bool = args.force | |
| solution: Path = args.solution | |
| dest: Path | None = args.dest | |
| check_solution_path(solution) | |
| if dest is None: | |
| dest = solution.with_suffix(".zip") | |
| dest_exists = dest.is_file() | |
| if dest_exists and not check and not force: | |
| sys.exit( | |
| f"Destination {dest} already exists\n" | |
| f"(use -f/--force to overwrite, or -c/--check to compare contents)" | |
| ) | |
| elif dest_exists and check: | |
| compare_solution_archive(solution, dest) | |
| elif dest_exists and force: | |
| # NOTE: consider renaming dest before writing | |
| create_solution_archive(solution, dest) | |
| print("Overwritten", dest) | |
| elif check: | |
| sys.exit( | |
| f"Destination {dest} does not exist (omit -c/--check to write archive)" | |
| ) | |
| else: | |
| create_solution_archive(solution, dest) | |
| print("Saved to", dest) | |
| def check_solution_path(solution: Path) -> None: | |
| if not solution.is_dir(): | |
| sys.exit(f"Solution must be a directory, not {dir}") | |
| sln = (".sln", ".slnx") | |
| sln = [solution / solution.with_suffix(s) for s in sln] | |
| if not any(p.is_file() for p in sln): | |
| sys.exit(f"Directory is missing solution file, {' or '.join(p.name for p in sln)}") | |
| for child in solution.iterdir(): | |
| if child.is_dir() and any(child.glob("*.csproj")): | |
| break | |
| else: | |
| sys.exit("No projects found inside solution directory (.csproj)") | |
| def create_solution_archive(src: Path, dest: Path | IO[bytes]) -> None: | |
| with ZipFile(dest, "w", compression=ZIP_DEFLATED) as dest_zip: | |
| for path in filter_solution_files(src): | |
| print(path) | |
| dest_zip.write(path) | |
| def filter_solution_files(solution: Path) -> Iterator[Path]: | |
| for path in solution.iterdir(): | |
| if fnmatch_any(path.name, *IGNORE_GLOBAL_PATTERNS): | |
| continue | |
| elif path.is_dir(): | |
| yield from filter_project_files(path) | |
| else: | |
| yield path | |
| def filter_project_files(project: Path) -> Iterator[Path]: | |
| for path in project.iterdir(): | |
| if fnmatch_any(path.name, *IGNORE_PROJECT_PATTERNS, *IGNORE_GLOBAL_PATTERNS): | |
| continue | |
| else: | |
| yield path | |
| def fnmatch_any(name: str, *patterns: str) -> bool: | |
| return any(fnmatch.fnmatch(name, p) for p in patterns) | |
| def hexdigest(data: bytes | IO[bytes]) -> str: | |
| m = hashlib.sha3_256() | |
| if isinstance(data, (bytes, bytearray, memoryview)): | |
| m.update(data) | |
| else: | |
| while chunk := data.read(2**20): | |
| m.update(chunk) | |
| return m.hexdigest() | |
| def compare_solution_archive(src: Path, dest: Path) -> None: | |
| lines: list[str] = [] | |
| with ZipFile(dest) as dest_zip: | |
| deleted = {Path(info.filename) for info in dest_zip.filelist} | |
| for path in filter_solution_files(src): | |
| deleted.discard(path) | |
| try: | |
| with dest_zip.open(str(PurePosixPath(path))) as f: | |
| old = hexdigest(f) | |
| except KeyError: | |
| lines.append(f"A {path}") | |
| continue | |
| with path.open("rb") as f: | |
| new = hexdigest(f) | |
| if old != new: | |
| lines.append(f"M {path}") | |
| for path in deleted: | |
| lines.append(f"D {path}") | |
| if not lines: | |
| lines.append("No changes in content") | |
| print("\n".join(lines)) | |
| if __name__ == "__main__": | |
| main() |
Author
thegamecracks
commented
Jan 15, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment