Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,23 @@ jobs:
- target: linux-x64
runner: ubuntu-latest
asset: wild-linux-x64
smoke: ./release/wild-linux-x64 --help
smoke: python scripts/release_smoke.py ./release/wild-linux-x64
- target: linux-arm64
runner: ubuntu-24.04-arm
asset: wild-linux-arm64
smoke: ./release/wild-linux-arm64 --help
smoke: python scripts/release_smoke.py ./release/wild-linux-arm64
- target: macos-x64
runner: macos-15-intel
asset: wild-macos-x64
smoke: ./release/wild-macos-x64 --help
smoke: python scripts/release_smoke.py ./release/wild-macos-x64
- target: macos-arm64
runner: macos-15
asset: wild-macos-arm64
smoke: ./release/wild-macos-arm64 --help
smoke: python scripts/release_smoke.py ./release/wild-macos-arm64
- target: windows-x64
runner: windows-latest
asset: wild-win.exe
smoke: ./release/wild-win.exe --help
smoke: python scripts/release_smoke.py ./release/wild-win.exe

runs-on: ${{ matrix.runner }}
steps:
Expand All @@ -106,6 +106,8 @@ jobs:
else
mv release/wild "release/${{ matrix.asset }}"
fi
# A real diff (JSON, HTML, terminal), not just --help: --help cannot
# catch missing runtime data such as the DiffGraph schema.
- name: Smoke test native executable
shell: bash
run: ${{ matrix.smoke }}
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.3.1] - 2026-11-05

### Fixed
- Native release binaries can run `wild diff` again. The cli-v1.3.0 binaries
were built without the packaged DiffGraph schema, so every canonical diff
(terminal, HTML, JSON) failed with "could not load canonical DiffGraph
schema". The build now bundles an explicit allowlist of runtime package
data (only the schema); `.env` files are still refused.
- Non-diff commands pass through to git verbatim again. `wild log
--format=…`, `wild show --format=…`, and `wild format-patch -o DIR` failed
because wild parsed its own `--format`/`--output` options for every
command.
- Release smoke tests are an end-to-end scenario suite instead of `--help`:
the exact commands the VS Code extension runs (health probe, working tree,
staged, history, including a parentless first commit), `REF`, `--staged
REF`, three-dot, pathspec, binary file, no-change, usage and runtime exit
codes, and git passthrough. Every JSON artifact is schema-validated. All
five release platforms run it on the native binary, and CI runs it on the
installed CLI.

## [1.3.0] - 2026-10-26

### Added
Expand Down
32 changes: 28 additions & 4 deletions build.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
#!/usr/bin/env python3
"""Build a native, single-file ``wild`` executable with PyInstaller.

This builder deliberately never supplies PyInstaller with ``--add-data``. In
particular, a local ``.env`` is rejected before the build starts so credentials
cannot accidentally become part of a release executable.
The only data bundled is ``PACKAGED_DATA_FILES``: an explicit allowlist of
exact package files the CLI loads at runtime (the DiffGraph schema every
canonical artifact is validated against). Nothing else is ever passed to
PyInstaller as data, and a local ``.env`` is rejected before the build starts
so credentials cannot become part of a release executable.
"""

from __future__ import annotations

import argparse
import os
import shutil
import subprocess
import sys
Expand All @@ -18,6 +21,11 @@
ROOT = Path(__file__).resolve().parent
ENTRYPOINT = ROOT / "diffgraph" / "cli.py"
FORBIDDEN_BUNDLE_FILE_NAMES = frozenset({".env"})
# Runtime data loaded through importlib.resources. A missing entry here breaks
# every `wild diff` in the native binary (cli-v1.3.0 shipped without it).
PACKAGED_DATA_FILES = (
ROOT / "diffgraph" / "schema" / "diffgraph-v2.schema.json",
)


def find_forbidden_files(root: Path) -> list[Path]:
Expand All @@ -44,8 +52,23 @@ def assert_release_workspace_safe(root: Path) -> None:
)


def packaged_data_arguments() -> list[str]:
"""``--add-data`` arguments for exactly the allowlisted package files."""
arguments: list[str] = []
package_root = (ROOT / "diffgraph").resolve()
for listed in PACKAGED_DATA_FILES:
# Resolve symlinks first: a link inside the package must not pull an
# outside file into the release binary.
path = listed.resolve()
if not path.is_file() or package_root not in path.parents:
raise SystemExit(f"packaged data file is missing or outside the package: {listed}")
destination = path.parent.relative_to(ROOT.resolve()).as_posix()
arguments += ["--add-data", f"{path}{os.pathsep}{destination}"]
return arguments


def build_command(*, output_dir: Path, work_dir: Path, spec_dir: Path, name: str) -> list[str]:
"""Return the intentionally data-free native PyInstaller invocation."""
"""Return the native PyInstaller invocation; data is only the allowlist."""
return [
sys.executable,
"-m",
Expand All @@ -62,6 +85,7 @@ def build_command(*, output_dir: Path, work_dir: Path, spec_dir: Path, name: str
str(work_dir),
"--specpath",
str(spec_dir),
*packaged_data_arguments(),
str(ENTRYPOINT),
]

Expand Down
2 changes: 1 addition & 1 deletion diffgraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
DiffGraph - A CLI tool for visualizing code changes with AI
"""

__version__ = "1.3.0"
__version__ = "1.3.1"
33 changes: 32 additions & 1 deletion diffgraph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,38 @@ class _RawArgsCommand(click.Command):

def parse_args(self, ctx, args):
ctx.meta["raw_args"] = tuple(args)
return super().parse_args(ctx, args)
command_index = self._command_index(ctx, args)
if command_index is None or args[command_index] == "diff":
return super().parse_args(ctx, args)
# Every other command belongs to git verbatim: `wild log --format=%h`
# or `wild format-patch -o dir` must not be parsed as wild's options.
remaining = super().parse_args(ctx, list(args[:command_index]))
ctx.params["args"] = tuple(args[command_index:])
return remaining

def _command_index(self, ctx, args):
"""Index of the first argument after wild's own leading options."""
takes_value = set()
known = set(self.get_help_option_names(ctx))
for param in self.params:
if isinstance(param, click.Option):
names = [*param.opts, *param.secondary_opts]
known.update(names)
if not param.is_flag and not param.count:
takes_value.update(names)
index = 0
while index < len(args):
argument = args[index]
if argument == "--":
return None
name = argument.split("=", 1)[0]
if argument.startswith("-") and name in known:
index += 2 if name in takes_value and "=" not in argument else 1
continue
# The command, or a git global option such as `-C DIR`, `-c k=v`,
# or `--no-pager`: everything from here belongs to git.
return index
return None


def _terminal_options(
Expand Down
Loading
Loading