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
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Tests

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: python -m pip install --upgrade pip
- run: python -m pip install -r requirements.txt -e . pytest
- run: python -m pytest
- run: python -m compileall -q diffgraph tests
- run: git diff --check
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,40 @@ This will:
- `--api-key`: Specify your OpenAI API key (defaults to OPENAI_API_KEY environment variable)
- `--output` or `-o`: Specify the output HTML file path (default: diffgraph.html)
- `--no-open`: Don't automatically open the HTML report in browser
- `--structural-json`: Write a local Python structural DiffGraph v2 artifact to the given path (`-` for stdout). Applies to `wild diff` only.
- `--version`: Show version information

Example:
```bash
wild --output my-report.html --no-open
```

### Local structural JSON (experimental)

A deterministic, network-free Python baseline can be written as a validated
DiffGraph v2 artifact without changing the existing AI/HTML default:

```bash
wild --structural-json diffgraph.json diff
wild --structural-json staged.json diff --staged -- src/
wild --structural-json - diff -- path/to/file.py
```

This increment intentionally supports only local unstaged (`index` → working
tree) and staged (`HEAD` → index) snapshots. Put pathspecs after `--`.
Pathspecs are interpreted relative to the directory where `wild` is invoked,
matching Git's command-line behavior. Commit ranges are rejected rather than
analyzed with guessed semantics.

Python (`.py`) is the only language with structural symbol/import extraction in
this baseline. Other changed files remain in `files[]` and receive a scoped
`UNSUPPORTED_LANGUAGE` warning. Syntax/decoding failures receive a scoped
`PARSE_FAILURE` warning and do not produce invented symbol changes. Import
targets are explicitly labeled unresolved/external; no project-wide resolution
is claimed. Every file records old/new paths, modes, Git object IDs, and content
SHA-256 values in structural evidence, while symbol/relationship evidence names
the parser package, query revision, and source blob identity.

## 📊 Example Output

The generated HTML report includes:
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__ = "0.1.0"
__version__ = "1.1.0"
131 changes: 125 additions & 6 deletions diffgraph/cli.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import json
import subprocess
import sys
from pathlib import Path
import click
from click_spinner import spinner
from typing import List, Dict
import os
from diffgraph.ai_analysis import CodeAnalysisAgent
from diffgraph.html_report import generate_html_report, AnalysisResult
from diffgraph import __version__
from diffgraph.env_loader import load_env_file, debug_environment
from diffgraph.git_snapshot import GitSnapshotError
from diffgraph.utils import sanitize_diff_args, involves_working_tree
from diffgraph.structural import StructuralDependencyError, analyze_local_diff

# Load environment variables
load_env_file()
Expand Down Expand Up @@ -86,6 +87,77 @@ def get_changed_files(diff_args: List[str] = None) -> List[Dict[str, str]]:

return changed_files

class _RawArgsCommand(click.Command):
"""Retain the raw separator that Click removes from variadic arguments."""

def parse_args(self, ctx, args):
ctx.meta["raw_args"] = tuple(args)
return super().parse_args(ctx, args)


def _separator_follows_diff(raw_args) -> bool:
"""Return whether the raw CLI placed ``--`` after the ``diff`` operand."""

value_options = {"--api-key", "--output", "-o", "--structural-json"}
index = 0
while index < len(raw_args):
argument = raw_args[index]
if argument in value_options:
index += 2
continue
if any(argument.startswith(option + "=") for option in value_options):
index += 1
continue
if argument.startswith("-o") and argument != "-o":
index += 1
continue
if argument == "diff":
return "--" in raw_args[index + 1 :]
index += 1
return False


def _structural_scope(diff_args: List[str], separator_present: bool = False):
"""Accept only the exact local snapshot modes implemented by this increment."""
staged = False
pathspecs = []
after_separator = separator_present
for argument in diff_args:
if argument == "--":
after_separator = True
elif argument in ("--staged", "--cached") and not after_separator:
staged = True
elif after_separator:
pathspecs.append(argument)
else:
raise click.UsageError(
"--structural-json currently supports only unstaged or --staged/--cached "
"local diffs; put pathspecs after '--'"
)
return staged, pathspecs


def _validate_structural_artifact(artifact):
"""Fail closed when the canonical v2 schema cannot validate the artifact."""
try:
import jsonschema
except ImportError as error:
raise click.ClickException(
"jsonschema is required to validate --structural-json output"
) from error
schema_path = Path(__file__).parent / "schema" / "diffgraph-v2.schema.json"
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
jsonschema.validate(artifact, schema)
except (
OSError,
json.JSONDecodeError,
jsonschema.ValidationError,
jsonschema.SchemaError,
) as error:
raise click.ClickException(f"structural artifact validation failed: {error}") from error
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] = None) -> List[Dict[str, str]]:
"""
Load contents of changed files.
Expand Down Expand Up @@ -129,16 +201,27 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str]

return files_with_content

@click.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
@click.command(
cls=_RawArgsCommand,
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
)
@click.version_option(package_name='wild')
@click.argument('args', nargs=-1, type=click.UNPROCESSED)
@click.option('--api-key', envvar='OPENAI_API_KEY', help='OpenAI API key')
@click.option('--output', '-o', default='diffgraph.html', help='Output HTML file path')
@click.option('--no-open', is_flag=True, help='Do not open the HTML report automatically')
@click.option('--debug-env', is_flag=True, help='Debug environment variable loading')
def main(args, api_key: str, output: str, no_open: bool, debug_env: bool):
@click.option(
'--structural-json',
type=click.Path(dir_okay=False, path_type=Path),
help="Write the local Python structural DiffGraph v2 artifact ('-' for stdout)",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
def main(args, api_key: str, output: str, no_open: bool, debug_env: bool, structural_json: Path):
"""wild - Git wrapper CLI with DiffGraph for diff commands."""

if structural_json is not None and (not args or args[0] != "diff"):
raise click.UsageError("--structural-json can only be used with 'diff'")

# Check if this is a diff command
if args and args[0] == 'diff':
# Handle diff command with custom logic
Expand All @@ -153,6 +236,42 @@ def main(args, api_key: str, output: str, no_open: bool, debug_env: bool):
click.echo("❌ Error: Not a git repository", err=True)
sys.exit(1)

if structural_json is not None:
raw_args = click.get_current_context().meta.get("raw_args", ())
staged, pathspecs = _structural_scope(
diff_args, separator_present=_separator_follows_diff(raw_args)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
artifact = analyze_local_diff(
".", staged=staged, pathspecs=pathspecs, wild_version=__version__
)
except (GitSnapshotError, StructuralDependencyError) as error:
raise click.ClickException(str(error)) from error
_validate_structural_artifact(artifact)
rendered = json.dumps(artifact, indent=2, sort_keys=True) + "\n"
if str(structural_json) == "-":
click.echo(rendered, nl=False)
else:
try:
structural_json.write_text(rendered, encoding="utf-8")
except OSError as error:
raise click.ClickException(
f"could not write {structural_json}: {error}"
) from error
click.echo(f"✅ Structural DiffGraph written: {structural_json}", err=True)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return

# Keep the legacy AI/HTML path lazy so local structural output never
# imports a network-capable SDK.
try:
from click_spinner import spinner
from diffgraph.ai_analysis import CodeAnalysisAgent
from diffgraph.html_report import generate_html_report, AnalysisResult
except ImportError as error:
raise click.ClickException(
f"The AI report path requires additional dependencies: {error}"
) from error

click.echo("🔍 Scanning for changed files...")
changed_files = get_changed_files(diff_args)

Expand Down Expand Up @@ -233,4 +352,4 @@ def progress_callback(current_file, total_files, status):
sys.exit(1)

if __name__ == "__main__":
main()
main()
Loading
Loading