diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..309034a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: test + +on: + push: + pull_request: + +jobs: + unit-tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Run unit tests + run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fee6b89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.contextzip/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.DS_Store diff --git a/README.md b/README.md index 07e2a7f..bbaf7fa 100644 --- a/README.md +++ b/README.md @@ -1 +1,159 @@ # ContextZIP + +Prepare a local project for ChatGPT web without rewriting its files. + +ContextZIP is a lightweight Codex skill that copies the project files you choose into a flat, drag-and-drop upload folder. The copied file contents stay byte-for-byte identical to the originals. ContextZIP does not summarize, concatenate, compress, or send files to an API. + +## Why + +Local coding agents are convenient for editing a repository, while ChatGPT web can be convenient for longer design discussions, debugging, research, and review. Moving the relevant project context between those environments is usually manual and error-prone. + +ContextZIP turns this: + +```text +my-project/ +├── README.md +├── src/ +│ └── retrieval/ +│ └── router.py +├── tests/ +│ └── test_router.py +└── papers/ + └── bright.pdf +``` + +into this: + +```text +my-project/.contextzip/upload/ +├── README.md +├── src__retrieval__router.py +├── tests__test_router.py +└── papers__bright.pdf +``` + +The files can then be selected together and dragged into a ChatGPT conversation or project. + +Directory separators become `__`. Extensionless files such as `Dockerfile` receive a `.txt` suffix for web upload compatibility. Only the output filename changes; the file bytes do not. + +## Core principles + +- **Original contents only** - copied files are verified byte-for-byte. +- **No wrapper tokens** - no XML, Markdown fences, generated summaries, or concatenation. +- **Local only** - no API key, hosted service, or automatic upload. +- **Deterministic** - selection uses paths, Git metadata, ignore rules, and explicit options rather than an LLM reading every file. +- **Safe by default** - common secrets, generated folders, symlinks, and oversized files are blocked or skipped. + +ContextZIP reduces packaging overhead. It does not compress the original text tokens inside a file. + +## Install as a Codex skill + +Clone the repository into your Codex skills directory: + +```bash +git clone https://github.com/junjunjunbong/ContextZIP.git ~/.codex/skills/contextzip +``` + +Restart Codex if the skill is not discovered immediately. + +## Use + +In Codex: + +```text +$contextzip +``` + +Useful variations: + +```text +$contextzip Prepare all eligible files in this project for ChatGPT web. +$contextzip Prepare only my current Git changes and the root context files. +$contextzip Dry-run first and do not open Finder. +``` + +The skill creates: + +```text +/.contextzip/ +├── upload/ # drag these files into ChatGPT +└── manifest.json # local mapping and integrity record; not uploaded by default +``` + +## Direct script use + +The script uses only the Python standard library and requires Python 3.10 or later. + +Preview all eligible files: + +```bash +python3 scripts/pack.py --root /path/to/project --mode all --dry-run +``` + +Create a pack: + +```bash +python3 scripts/pack.py \ + --root /path/to/project \ + --mode all \ + --max-files 40 \ + --open +``` + +Create a smaller pack from current Git changes plus root context files: + +```bash +python3 scripts/pack.py \ + --root /path/to/project \ + --mode current \ + --max-files 40 \ + --open +``` + +Use `--help` for all options. + +## Selection behavior + +When the project is a Git repository, ContextZIP uses Git to collect tracked files and unignored untracked files. This respects `.gitignore` without implementing a second ignore parser. + +`--mode all` selects every eligible file. + +`--mode current` selects files changed relative to `HEAD`, untracked files, and a small set of root context files such as `README.md`, `AGENTS.md`, `pyproject.toml`, and `package.json`. + +Common generated directories are excluded, including: + +```text +.git, .contextzip, node_modules, .venv, venv, __pycache__, +dist, build, target, .next, coverage, and common tool caches +``` + +Common sensitive paths are blocked, including `.env*`, private keys, credential files, and service-account secrets. This is a path-based guardrail, not a full content secret scanner. Review the output before uploading it. + +## Supported files + +The default allowlist covers common source code, configuration, text, research, office, notebook, and PDF formats. Use repeated `--include` patterns to include additional paths explicitly, and repeated `--exclude` patterns to remove paths. + +Examples: + +```bash +python3 scripts/pack.py --root . --include 'notes/**' --include '*.log' +python3 scripts/pack.py --root . --exclude 'outputs/**' --exclude '**/fixtures/**' +``` + +Explicit includes do not override the sensitive-file blocklist. + +## What ContextZIP intentionally does not do + +- It does not summarize or rewrite files. +- It does not concatenate a repository into one generated document. +- It does not semantically rank files with an LLM. +- It does not upload anything to ChatGPT automatically. +- It does not claim to reduce the token count of the original file contents. + +## Development + +Run the tests with: + +```bash +python3 -m unittest discover -s tests -v +``` diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..2a1809f --- /dev/null +++ b/SKILL.md @@ -0,0 +1,73 @@ +--- +name: contextzip +description: Prepare a local project for ChatGPT web by copying selected original files byte-for-byte into a flat upload folder. Use when the user wants to move project context from Codex or another local workspace into ChatGPT, create a drag-and-drop context pack, or avoid concatenating and summarizing project files. +--- + +# ContextZIP + +Create a lightweight upload pack from the current local project. + +## Core contract + +- Never summarize, concatenate, compress, normalize, or rewrite source file contents. +- Do not read file bodies to decide relevance unless the user explicitly requests content-based selection. +- Prefer path names, extensions, Git status, `.gitignore`, file sizes, and explicit user scope. +- Copy selected files byte-for-byte into a flat output directory. +- Keep `manifest.json` outside the upload directory so it does not add context unless the user chooses to upload it. +- Never upload files automatically. +- Never bypass the sensitive-path blocklist unless the user explicitly edits the script themselves. + +## Workflow + +1. Determine the project root. + - Prefer `git rev-parse --show-toplevel` when available. + - Otherwise use the current working directory. +2. Resolve `scripts/pack.py` relative to this `SKILL.md` file. +3. Choose a mode. + - Use `current` when the user says current, changed, recent, debug, or working files. + - Otherwise use `all`. +4. Run a dry-run first with a 40-file upload budget unless the user specifies another limit. + +```bash +python3 /scripts/pack.py \ + --root \ + --mode \ + --max-files 40 \ + --dry-run +``` + +5. If `all` exceeds the file budget and the user did not explicitly request every file, retry the dry-run with `--mode current`. +6. Do not silently truncate files. If the selected mode still exceeds the requested budget, report the count and ask the user to narrow the scope or provide include/exclude patterns. +7. Create the pack. Open the output folder unless the user asks not to. + +```bash +python3 /scripts/pack.py \ + --root \ + --mode \ + --max-files 40 \ + --open +``` + +8. Report: + - selected and copied file counts + - skipped and blocked counts + - total copied bytes + - output directory + - whether integrity verification passed + +## Output + +```text +/.contextzip/ +├── upload/ +│ ├── README.md +│ ├── src__router.py +│ └── tests__test_router.py +└── manifest.json +``` + +The flattened filename encodes the original relative path with `__`. Extensionless files receive a `.txt` suffix for web upload compatibility. The manifest records the exact source-to-output mapping and SHA-256 hashes. + +## Important interpretation + +ContextZIP avoids generated wrapper text and unnecessary files. It does not compress or reduce the original tokens inside selected files. Describe the benefit as low-overhead context packaging, not token compression. diff --git a/scripts/pack.py b/scripts/pack.py new file mode 100755 index 0000000..c80cff0 --- /dev/null +++ b/scripts/pack.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +"""Create a flat, byte-preserving project context pack for ChatGPT web.""" + +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +import shutil +import subprocess +import sys +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Sequence + +DEFAULT_OUTPUT_DIR = ".contextzip/upload" +DEFAULT_MANIFEST = ".contextzip/manifest.json" +DEFAULT_MAX_FILE_SIZE_MB = 50.0 +MAX_OUTPUT_FILENAME_BYTES = 240 + +SUPPORTED_SUFFIXES = { + ".bash", + ".bib", + ".c", + ".cc", + ".cfg", + ".conf", + ".cpp", + ".cs", + ".css", + ".csv", + ".docx", + ".fish", + ".go", + ".gql", + ".graphql", + ".h", + ".hpp", + ".htm", + ".html", + ".ini", + ".ipynb", + ".java", + ".js", + ".json", + ".jsx", + ".kt", + ".kts", + ".less", + ".lua", + ".md", + ".mdx", + ".mjs", + ".pdf", + ".php", + ".pptx", + ".proto", + ".ps1", + ".py", + ".r", + ".rb", + ".rst", + ".rs", + ".rtf", + ".sass", + ".scala", + ".scss", + ".sh", + ".sql", + ".svelte", + ".swift", + ".tex", + ".toml", + ".ts", + ".tsx", + ".txt", + ".vue", + ".xlsx", + ".xml", + ".yaml", + ".yml", + ".zsh", +} + +SUPPORTED_EXTENSIONLESS_NAMES = { + "agents.md", + "claude.md", + "dockerfile", + "gemfile", + "justfile", + "license", + "makefile", + "procfile", + "readme", + "skill.md", + ".dockerignore", + ".editorconfig", + ".gitattributes", + ".gitignore", +} + +EXCLUDED_DIRECTORY_NAMES = { + ".cache", + ".contextzip", + ".git", + ".gradle", + ".idea", + ".mypy_cache", + ".next", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + ".vscode", + "__pycache__", + "build", + "coverage", + "dist", + "htmlcov", + "node_modules", + "target", + "venv", +} + +SENSITIVE_EXACT_NAMES = { + ".netrc", + ".npmrc", + ".pypirc", + "credentials.json", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "id_rsa", + "service-account.json", +} + +SENSITIVE_DIRECTORY_NAMES = { + ".aws", + ".gnupg", + ".ssh", + "credentials", + "secrets", +} + +SENSITIVE_SUFFIXES = { + ".jks", + ".key", + ".p12", + ".pfx", + ".pem", +} + +SENSITIVE_GLOBS = ( + ".env", + ".env.*", + "*client_secret*.json", + "*service-account*.json", + "*service_account*.json", + "credentials.*", + "secrets.*", +) + +ROOT_CONTEXT_GLOBS = ( + "README*", + "AGENTS.md", + "CLAUDE.md", + "SKILL.md", + "pyproject.toml", + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "uv.lock", + "requirements*.txt", + "Cargo.toml", + "Cargo.lock", + "go.mod", + "go.sum", + "Makefile", + "Dockerfile", + "docker-compose*.yml", + "docker-compose*.yaml", + ".gitignore", +) + + +@dataclass(frozen=True) +class SelectedFile: + source: str + output: str + bytes: int + sha256: str + + +@dataclass(frozen=True) +class SkippedFile: + source: str + reason: str + + +class PackError(RuntimeError): + """Raised when a pack cannot be created safely.""" + + +def run_git(root: Path, args: Sequence[str]) -> bytes | None: + """Run Git in root and return stdout, or None when unavailable/failing.""" + try: + completed = subprocess.run( + ["git", "-C", str(root), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return None + return completed.stdout + + +def is_git_repository(root: Path) -> bool: + result = run_git(root, ["rev-parse", "--is-inside-work-tree"]) + return result is not None and result.strip() == b"true" + + +def decode_null_separated(raw: bytes | None) -> list[str]: + if not raw: + return [] + return [item.decode("utf-8", errors="surrogateescape") for item in raw.split(b"\0") if item] + + +def git_all_paths(root: Path) -> list[Path] | None: + raw = run_git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) + if raw is None: + return None + return [Path(item) for item in decode_null_separated(raw)] + + +def git_current_paths(root: Path) -> list[Path] | None: + changed_raw = run_git(root, ["diff", "--name-only", "-z", "HEAD"]) + untracked_raw = run_git(root, ["ls-files", "--others", "--exclude-standard", "-z"]) + if changed_raw is None or untracked_raw is None: + return None + + paths = {Path(item) for item in decode_null_separated(changed_raw)} + paths.update(Path(item) for item in decode_null_separated(untracked_raw)) + + all_paths = git_all_paths(root) or [] + for path in all_paths: + if len(path.parts) == 1 and any( + fnmatch.fnmatch(path.name.lower(), pattern.lower()) for pattern in ROOT_CONTEXT_GLOBS + ): + paths.add(path) + return sorted(paths, key=lambda path: path.as_posix()) + + +def walk_paths(root: Path) -> list[Path]: + paths: list[Path] = [] + for current_dir, directory_names, file_names in os.walk(root): + directory_names[:] = sorted( + name for name in directory_names if name not in EXCLUDED_DIRECTORY_NAMES + ) + current = Path(current_dir) + for file_name in sorted(file_names): + absolute = current / file_name + try: + relative = absolute.relative_to(root) + except ValueError: + continue + paths.append(relative) + return paths + + +def path_matches(path: Path, patterns: Sequence[str]) -> bool: + value = path.as_posix() + return any(fnmatch.fnmatch(value, pattern) for pattern in patterns) + + +def has_excluded_directory(path: Path) -> bool: + return any(part in EXCLUDED_DIRECTORY_NAMES for part in path.parts[:-1]) + + +def is_sensitive(path: Path) -> bool: + lower_name = path.name.lower() + if any(part.lower() in SENSITIVE_DIRECTORY_NAMES for part in path.parts[:-1]): + return True + if lower_name in SENSITIVE_EXACT_NAMES: + return True + if path.suffix.lower() in SENSITIVE_SUFFIXES: + return True + return any(fnmatch.fnmatch(lower_name, pattern.lower()) for pattern in SENSITIVE_GLOBS) + + +def is_supported(path: Path) -> bool: + lower_name = path.name.lower() + return path.suffix.lower() in SUPPORTED_SUFFIXES or lower_name in SUPPORTED_EXTENSIONLESS_NAMES + + +def select_candidates( + root: Path, + mode: str, + includes: Sequence[str], + excludes: Sequence[str], + max_file_size_bytes: int, +) -> tuple[list[Path], list[SkippedFile]]: + """Select eligible relative paths without reading file bodies.""" + git_paths: list[Path] | None = None + if is_git_repository(root): + git_paths = git_current_paths(root) if mode == "current" else git_all_paths(root) + + candidates = git_paths if git_paths is not None else walk_paths(root) + candidates = sorted(set(candidates), key=lambda path: path.as_posix()) + + selected: list[Path] = [] + skipped: list[SkippedFile] = [] + + for relative in candidates: + source_label = relative.as_posix() + source_path = root / relative + if source_path.is_symlink(): + skipped.append(SkippedFile(source_label, "symlink")) + continue + absolute = source_path.resolve() + + try: + absolute.relative_to(root.resolve()) + except ValueError: + skipped.append(SkippedFile(source_label, "outside project root")) + continue + + if has_excluded_directory(relative): + skipped.append(SkippedFile(source_label, "excluded directory")) + continue + if path_matches(relative, excludes): + skipped.append(SkippedFile(source_label, "user exclude pattern")) + continue + if is_sensitive(relative): + skipped.append(SkippedFile(source_label, "sensitive path")) + continue + if not absolute.is_file(): + skipped.append(SkippedFile(source_label, "not a regular file")) + continue + + explicitly_included = path_matches(relative, includes) + if not explicitly_included and not is_supported(relative): + skipped.append(SkippedFile(source_label, "unsupported file type")) + continue + + size = absolute.stat().st_size + if size > max_file_size_bytes: + skipped.append(SkippedFile(source_label, "file exceeds size limit")) + continue + + selected.append(relative) + + return selected, skipped + + +def safe_flat_name(relative: Path, used_names: set[str]) -> str: + """Encode a relative path as one visible, collision-safe filename.""" + parts = [] + for part in relative.parts: + visible = f"dot_{part[1:]}" if part.startswith(".") else part + parts.append(visible) + candidate = "__".join(parts) + if not relative.suffix: + candidate = f"{candidate}.txt" + + digest = hashlib.sha256(relative.as_posix().encode("utf-8")).hexdigest()[:10] + encoded = candidate.encode("utf-8") + if len(encoded) > MAX_OUTPUT_FILENAME_BYTES: + suffix = Path(candidate).suffix + reserve = len((f"--{digest}{suffix}").encode("utf-8")) + budget = max(1, MAX_OUTPUT_FILENAME_BYTES - reserve) + prefix_bytes = candidate[: -len(suffix) if suffix else None].encode("utf-8")[:budget] + prefix = prefix_bytes.decode("utf-8", errors="ignore") + candidate = f"{prefix}--{digest}{suffix}" + + if candidate in used_names: + suffix = Path(candidate).suffix + stem = candidate[: -len(suffix)] if suffix else candidate + candidate = f"{stem}--{digest}{suffix}" + + counter = 2 + unique_candidate = candidate + while unique_candidate in used_names: + suffix = Path(candidate).suffix + stem = candidate[: -len(suffix)] if suffix else candidate + unique_candidate = f"{stem}-{counter}{suffix}" + counter += 1 + + used_names.add(unique_candidate) + return unique_candidate + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def ensure_safe_output(root: Path, output: Path) -> None: + root_resolved = root.resolve() + output_resolved = output.resolve() + try: + relative = output_resolved.relative_to(root_resolved) + except ValueError as exc: + raise PackError("Output directory must be inside the project root") from exc + if not relative.parts or relative.parts[0] != ".contextzip": + raise PackError("Output directory must be inside /.contextzip") + + +def prepare_output(output: Path) -> None: + if output.exists(): + shutil.rmtree(output) + output.mkdir(parents=True, exist_ok=True) + + +def create_pack( + root: Path, + selected: Sequence[Path], + skipped: Sequence[SkippedFile], + output: Path, + manifest_path: Path, + mode: str, + verify: bool, +) -> dict[str, object]: + ensure_safe_output(root, output) + prepare_output(output) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + + used_names: set[str] = set() + copied: list[SelectedFile] = [] + + for relative in selected: + source = root / relative + output_name = safe_flat_name(relative, used_names) + destination = output / output_name + shutil.copyfile(source, destination) + + source_hash = sha256_file(source) + if verify: + destination_hash = sha256_file(destination) + if source_hash != destination_hash: + raise PackError(f"Integrity verification failed for {relative.as_posix()}") + + copied.append( + SelectedFile( + source=relative.as_posix(), + output=output_name, + bytes=source.stat().st_size, + sha256=source_hash, + ) + ) + + manifest: dict[str, object] = { + "format_version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "project_name": root.name, + "mode": mode, + "integrity_verified": verify, + "upload_directory": output.relative_to(root).as_posix(), + "files": [asdict(item) for item in copied], + "skipped": [asdict(item) for item in skipped], + "totals": { + "copied_files": len(copied), + "copied_bytes": sum(item.bytes for item in copied), + "skipped_files": len(skipped), + }, + } + manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return manifest + + +def open_directory(path: Path) -> None: + try: + if sys.platform == "darwin": + subprocess.Popen(["open", str(path)]) + elif os.name == "nt": + os.startfile(path) # type: ignore[attr-defined] + else: + subprocess.Popen(["xdg-open", str(path)]) + except (FileNotFoundError, OSError) as exc: + print(f"Warning: could not open output directory: {exc}", file=sys.stderr) + + +def format_bytes(value: int) -> str: + amount = float(value) + for unit in ("B", "KB", "MB", "GB"): + if amount < 1024 or unit == "GB": + return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B" + amount /= 1024 + return f"{value} B" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Copy eligible project files byte-for-byte into a flat ChatGPT upload folder." + ) + parser.add_argument("--root", type=Path, default=Path.cwd(), help="Project root. Defaults to cwd.") + parser.add_argument( + "--mode", + choices=("all", "current"), + default="all", + help="Select all eligible files, or current Git changes plus root context files.", + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help=f"Output directory relative to root. Defaults to {DEFAULT_OUTPUT_DIR}.", + ) + parser.add_argument( + "--manifest", + type=Path, + default=None, + help=f"Manifest path relative to root. Defaults to {DEFAULT_MANIFEST}.", + ) + parser.add_argument("--include", action="append", default=[], help="Additional include glob. Repeatable.") + parser.add_argument("--exclude", action="append", default=[], help="Exclude glob. Repeatable.") + parser.add_argument( + "--max-files", + type=int, + default=0, + help="Fail when selection exceeds this count. Zero means unlimited.", + ) + parser.add_argument( + "--max-file-size-mb", + type=float, + default=DEFAULT_MAX_FILE_SIZE_MB, + help=f"Skip files larger than this many MB. Default: {DEFAULT_MAX_FILE_SIZE_MB:g}.", + ) + parser.add_argument("--dry-run", action="store_true", help="Show the selection without copying files.") + parser.add_argument("--no-verify", action="store_true", help="Skip destination SHA-256 verification.") + parser.add_argument("--open", action="store_true", dest="open_output", help="Open the upload folder.") + return parser + + +def resolve_under_root(root: Path, value: Path | None, default: str) -> Path: + path = value if value is not None else Path(default) + return path if path.is_absolute() else root / path + + +def print_selection(selected: Sequence[Path], skipped: Sequence[SkippedFile], root: Path) -> None: + total_bytes = sum((root / path).stat().st_size for path in selected) + print(f"Selected: {len(selected)} files ({format_bytes(total_bytes)})") + for path in selected: + print(f" + {path.as_posix()}") + if skipped: + print(f"Skipped: {len(skipped)} files") + reason_counts: dict[str, int] = {} + for item in skipped: + reason_counts[item.reason] = reason_counts.get(item.reason, 0) + 1 + for reason, count in sorted(reason_counts.items()): + print(f" - {reason}: {count}") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + root = args.root.expanduser().resolve() + if not root.is_dir(): + parser.error(f"Project root is not a directory: {root}") + if args.max_files < 0: + parser.error("--max-files must be zero or greater") + if args.max_file_size_mb <= 0: + parser.error("--max-file-size-mb must be greater than zero") + + output = resolve_under_root(root, args.output, DEFAULT_OUTPUT_DIR).resolve() + manifest = resolve_under_root(root, args.manifest, DEFAULT_MANIFEST).resolve() + max_file_size_bytes = int(args.max_file_size_mb * 1024 * 1024) + + try: + selected, skipped = select_candidates( + root=root, + mode=args.mode, + includes=args.include, + excludes=args.exclude, + max_file_size_bytes=max_file_size_bytes, + ) + print_selection(selected, skipped, root) + + if not selected: + raise PackError("No eligible files were selected") + if args.max_files and len(selected) > args.max_files: + raise PackError( + f"Selected {len(selected)} files, exceeding --max-files {args.max_files}. " + "Use --mode current or add --exclude patterns; files were not truncated." + ) + if args.dry_run: + print("Dry-run complete. No files were copied.") + return 0 + + result = create_pack( + root=root, + selected=selected, + skipped=skipped, + output=output, + manifest_path=manifest, + mode=args.mode, + verify=not args.no_verify, + ) + except (PackError, OSError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + totals = result["totals"] + assert isinstance(totals, dict) + print("ContextZIP pack created.") + print(f"Copied: {totals['copied_files']} files ({format_bytes(int(totals['copied_bytes']))})") + print(f"Integrity verified: {result['integrity_verified']}") + print(f"Upload directory: {output}") + print(f"Manifest: {manifest}") + + if args.open_output: + open_directory(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_pack.py b/tests/test_pack.py new file mode 100644 index 0000000..1525c5e --- /dev/null +++ b/tests/test_pack.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import importlib.util +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "pack.py" +SPEC = importlib.util.spec_from_file_location("contextzip_pack", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +pack = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = pack +SPEC.loader.exec_module(pack) + + +class ContextZipTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def write_bytes(self, relative: str, content: bytes) -> Path: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return path + + def select_all(self): + return pack.select_candidates( + root=self.root, + mode="all", + includes=[], + excludes=[], + max_file_size_bytes=50 * 1024 * 1024, + ) + + def test_pack_preserves_bytes_and_keeps_manifest_outside_upload(self) -> None: + original = b"def answer():\n return 42\n" + self.write_bytes("src/main.py", original) + self.write_bytes("README.md", b"# Demo\n") + + selected, skipped = self.select_all() + output = self.root / ".contextzip/upload" + manifest_path = self.root / ".contextzip/manifest.json" + result = pack.create_pack( + root=self.root, + selected=selected, + skipped=skipped, + output=output, + manifest_path=manifest_path, + mode="all", + verify=True, + ) + + self.assertEqual((output / "src__main.py").read_bytes(), original) + self.assertTrue(manifest_path.is_file()) + self.assertFalse((output / "manifest.json").exists()) + self.assertTrue(result["integrity_verified"]) + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + source_map = {item["source"]: item["output"] for item in manifest["files"]} + self.assertEqual(source_map["src/main.py"], "src__main.py") + + def test_sensitive_generated_and_unsupported_files_are_excluded(self) -> None: + self.write_bytes("src/app.py", b"print('ok')\n") + self.write_bytes(".env", b"API_KEY=secret\n") + self.write_bytes("keys/private.pem", b"secret\n") + self.write_bytes("secrets/settings.yaml", b"password: secret\n") + self.write_bytes("node_modules/pkg/index.js", b"generated\n") + self.write_bytes("assets/archive.bin", b"binary\n") + + selected, skipped = self.select_all() + selected_names = {path.as_posix() for path in selected} + skipped_reasons = {(item.source, item.reason) for item in skipped} + + self.assertEqual(selected_names, {"src/app.py"}) + self.assertIn((".env", "sensitive path"), skipped_reasons) + self.assertIn(("keys/private.pem", "sensitive path"), skipped_reasons) + self.assertIn(("secrets/settings.yaml", "sensitive path"), skipped_reasons) + self.assertNotIn("node_modules/pkg/index.js", {item.source for item in skipped}) + self.assertIn(("assets/archive.bin", "unsupported file type"), skipped_reasons) + + def test_flattened_name_collision_gets_stable_hash(self) -> None: + self.write_bytes("a/b.py", b"nested\n") + self.write_bytes("a__b.py", b"flat\n") + + selected, skipped = self.select_all() + output = self.root / ".contextzip/upload" + manifest_path = self.root / ".contextzip/manifest.json" + result = pack.create_pack( + root=self.root, + selected=selected, + skipped=skipped, + output=output, + manifest_path=manifest_path, + mode="all", + verify=True, + ) + + names = [item["output"] for item in result["files"]] + self.assertEqual(len(names), 2) + self.assertEqual(len(set(names)), 2) + self.assertIn("a__b.py", names) + self.assertTrue(any(name.startswith("a__b--") for name in names)) + + def test_explicit_include_adds_unknown_extension_but_not_sensitive_file(self) -> None: + self.write_bytes("logs/failure.log", b"trace\n") + self.write_bytes("secrets/client_secret_dev.json", b"{}\n") + + selected, skipped = pack.select_candidates( + root=self.root, + mode="all", + includes=["logs/**", "secrets/**"], + excludes=[], + max_file_size_bytes=50 * 1024 * 1024, + ) + + self.assertEqual([path.as_posix() for path in selected], ["logs/failure.log"]) + self.assertIn( + ("secrets/client_secret_dev.json", "sensitive path"), + {(item.source, item.reason) for item in skipped}, + ) + + @unittest.skipUnless(shutil.which("git"), "git is required for current-mode test") + def test_current_mode_selects_changes_untracked_and_root_context(self) -> None: + subprocess.run(["git", "init"], cwd=self.root, check=True, stdout=subprocess.DEVNULL) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=self.root, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=self.root, check=True) + + self.write_bytes("README.md", b"# Demo\n") + self.write_bytes("src/changed.py", b"before\n") + self.write_bytes("src/unchanged.py", b"stable\n") + subprocess.run(["git", "add", "."], cwd=self.root, check=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=self.root, check=True, stdout=subprocess.DEVNULL) + + self.write_bytes("src/changed.py", b"after\n") + self.write_bytes("notes/new.md", b"new\n") + + selected, _ = pack.select_candidates( + root=self.root, + mode="current", + includes=[], + excludes=[], + max_file_size_bytes=50 * 1024 * 1024, + ) + selected_names = {path.as_posix() for path in selected} + + self.assertEqual(selected_names, {"README.md", "notes/new.md", "src/changed.py"}) + + def test_symlink_is_skipped_even_when_target_is_inside_project(self) -> None: + target = self.write_bytes("src/target.py", b"target\n") + link = self.root / "src/link.py" + try: + link.symlink_to(target.name) + except (OSError, NotImplementedError): + self.skipTest("symlinks are not available") + + selected, skipped = self.select_all() + self.assertEqual({path.as_posix() for path in selected}, {"src/target.py"}) + self.assertIn( + ("src/link.py", "symlink"), + {(item.source, item.reason) for item in skipped}, + ) + + def test_extensionless_file_gets_txt_suffix(self) -> None: + self.write_bytes("Dockerfile", b"FROM python:3.12\n") + selected, skipped = self.select_all() + result = pack.create_pack( + root=self.root, + selected=selected, + skipped=skipped, + output=self.root / ".contextzip/upload", + manifest_path=self.root / ".contextzip/manifest.json", + mode="all", + verify=True, + ) + self.assertEqual(result["files"][0]["output"], "Dockerfile.txt") + self.assertEqual( + (self.root / ".contextzip/upload/Dockerfile.txt").read_bytes(), + b"FROM python:3.12\n", + ) + + def test_cli_refuses_to_silently_truncate_max_files(self) -> None: + self.write_bytes("one.py", b"1\n") + self.write_bytes("two.py", b"2\n") + + return_code = pack.main( + [ + "--root", + str(self.root), + "--max-files", + "1", + "--dry-run", + ] + ) + self.assertEqual(return_code, 2) + self.assertFalse((self.root / ".contextzip/upload").exists()) + + +if __name__ == "__main__": + unittest.main()