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
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,26 @@ wild diff --format terminal
wild --structural-json diffgraph.json diff
wild --structural-json staged.json diff --staged -- src/
wild --structural-json - diff -- path/to/file.py
wild diff --format json HEAD~2..HEAD
wild diff --format html main...feature -- src/
```

`--structural-json PATH` remains a compatibility alias for canonical JSON with
that destination. Do not combine it with `--format` or `--output`; ambiguous
combinations are usage errors. Likewise, `--format terminal` cannot use
`--output`. Terminal-only `--compact` and `--all` flags follow `diff`.

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.
Canonical output supports local unstaged (`index` → working tree), staged
(`HEAD` → index), explicit two-dot (`BASE..HEAD`), and explicit three-dot
(`BASE...HEAD`) comparisons. Two-dot resolves both refs to immutable commits;
three-dot resolves their merge base and compares it with the immutable head,
matching Git diff semantics. The artifact records those exact comparison OIDs
and every file's pre/post blob identities. Invalid refs and unavailable merge
bases produce structured warnings instead of false changes.

Put pathspecs after `--`. Pathspecs are interpreted relative to the directory
where `wild` is invoked, matching Git's command-line behavior. Both endpoints
of a commit range are required; implicit-ref forms are rejected.

Python (`.py`) is the only language with structural symbol/import extraction in
this baseline. Other changed files remain in `files[]` and receive a scoped
Expand Down
8 changes: 7 additions & 1 deletion diffgraph/artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import os
import tempfile
from pathlib import Path
from typing import Sequence
from typing import Optional, Sequence

from diffgraph.contract import ValidatedArtifact
from diffgraph.structural import analyze_local_diff
Expand All @@ -22,13 +22,19 @@ def build_validated_artifact(
*,
staged: bool = False,
pathspecs: Sequence[str] = (),
base_ref: Optional[str] = None,
head_ref: Optional[str] = None,
three_dot: bool = False,
wild_version: str,
) -> ValidatedArtifact:
"""Construct and validate exactly one local structural artifact."""
artifact = analyze_local_diff(
repository,
staged=staged,
pathspecs=pathspecs,
base_ref=base_ref,
head_ref=head_ref,
three_dot=three_dot,
wild_version=wild_version,
)
return ValidatedArtifact.from_value(artifact)
Expand Down
133 changes: 105 additions & 28 deletions diffgraph/cli.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
import click
from click.core import ParameterSource
from typing import List, Dict
from typing import Dict, List, Optional, Tuple
import os
from diffgraph import __version__
from diffgraph.artifact import (
Expand Down Expand Up @@ -124,23 +125,35 @@ def parse_args(self, ctx, args):
return super().parse_args(ctx, args)


def _terminal_options(diff_args):
"""Remove terminal-only display flags from a ``diff`` invocation."""
def _terminal_options(
diff_args, explicit_pathspecs: Optional[Tuple[str, ...]] = None
):
"""Remove terminal-only flags without consuming pathspecs after ``--``."""
option_args = list(diff_args)
pathspec_args = []
if explicit_pathspecs:
pathspec_count = len(explicit_pathspecs)
if tuple(option_args[-pathspec_count:]) == explicit_pathspecs:
option_args, pathspec_args = (
option_args[:-pathspec_count],
option_args[-pathspec_count:],
)

remaining = []
compact = False
show_all = False
for arg in diff_args:
for arg in option_args:
if arg == "--compact":
compact = True
elif arg == "--all":
show_all = True
else:
remaining.append(arg)
return remaining, compact, show_all
return remaining + pathspec_args, compact, show_all


def _separator_follows_diff(raw_args) -> bool:
"""Return whether the raw CLI placed ``--`` after the ``diff`` operand."""
def _pathspecs_after_diff_separator(raw_args) -> Optional[Tuple[str, ...]]:
"""Return raw pathspecs after ``diff --``, preserving range-like names."""

value_options = {"--api-key", "--output", "-o", "--structural-json", "--format"}
index = 0
Expand All @@ -156,29 +169,84 @@ def _separator_follows_diff(raw_args) -> bool:
index += 1
continue
if argument == "diff":
return "--" in raw_args[index + 1 :]
trailing = raw_args[index + 1 :]
if "--" not in trailing:
return None
separator_index = trailing.index("--")
return tuple(trailing[separator_index + 1 :])
index += 1
return False
return None


@dataclass(frozen=True)
class _StructuralScope:
staged: bool = False
pathspecs: Tuple[str, ...] = ()
base_ref: Optional[str] = None
head_ref: Optional[str] = None
three_dot: bool = False


def _range_operand(argument: str):
"""Return exact two/three-dot endpoints, or ``None`` for another operand."""
if "...." in argument:
raise click.UsageError(
"commit ranges require explicit non-empty BASE..HEAD or BASE...HEAD refs"
)
separator = "..." if "..." in argument else ".." if ".." in argument else None
if separator is None:
return None
endpoints = argument.split(separator)
if len(endpoints) != 2 or not all(endpoints):
raise click.UsageError(
"commit ranges require explicit non-empty BASE..HEAD or BASE...HEAD refs"
)
return endpoints[0], endpoints[1], separator == "..."
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _structural_scope(
diff_args: List[str], explicit_pathspecs: Optional[Tuple[str, ...]] = None
):
"""Parse the deterministic local or immutable commit comparison scope."""
arguments = list(diff_args)
if "--" in arguments:
separator_index = arguments.index("--")
explicit_pathspecs = tuple(arguments[separator_index + 1 :])
arguments = arguments[:separator_index]
elif explicit_pathspecs is not None and explicit_pathspecs:
count = len(explicit_pathspecs)
if tuple(arguments[-count:]) != explicit_pathspecs:
raise click.UsageError("could not preserve pathspec scope after '--'")
arguments = arguments[:-count]

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:
base_ref = head_ref = None
three_dot = False
for argument in arguments:
if argument in ("--staged", "--cached"):
if base_ref is not None:
raise click.UsageError(
"--staged/--cached cannot be combined with a commit range"
)
staged = True
elif after_separator:
pathspecs.append(argument)
else:
continue
commit_range = _range_operand(argument)
if commit_range is None:
raise click.UsageError(
"--structural-json currently supports only unstaged or --staged/--cached "
"local diffs; put pathspecs after '--'"
"canonical output supports unstaged, --staged/--cached, or explicit "
"BASE..HEAD/BASE...HEAD diffs; put pathspecs after '--'"
)
return staged, pathspecs
if staged or base_ref is not None:
raise click.UsageError("use exactly one local mode or commit range")
base_ref, head_ref, three_dot = commit_range

return _StructuralScope(
staged,
explicit_pathspecs or (),
base_ref,
head_ref,
three_dot,
)


def _validate_structural_artifact(artifact):
Expand Down Expand Up @@ -321,15 +389,24 @@ def main(
if structural_json is not None or output_format in ("html", "terminal", "json"):
compact = False
show_all = False
if output_format == "terminal":
diff_args, compact, show_all = _terminal_options(diff_args)
raw_args = click.get_current_context().meta.get("raw_args", ())
staged, pathspecs = _structural_scope(
diff_args, separator_present=_separator_follows_diff(raw_args)
explicit_pathspecs = _pathspecs_after_diff_separator(raw_args)
if output_format == "terminal":
diff_args, compact, show_all = _terminal_options(
diff_args, explicit_pathspecs=explicit_pathspecs
)
scope = _structural_scope(
diff_args, explicit_pathspecs=explicit_pathspecs
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
artifact = build_validated_artifact(
".", staged=staged, pathspecs=pathspecs, wild_version=__version__
".",
staged=scope.staged,
pathspecs=scope.pathspecs,
base_ref=scope.base_ref,
head_ref=scope.head_ref,
three_dot=scope.three_dot,
wild_version=__version__,
)
except (
GitSnapshotError,
Expand Down
40 changes: 34 additions & 6 deletions diffgraph/structural.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
SnapshotEntry,
read_worktree_blob,
repository_root,
resolve_commit_range,
resolve_staged,
resolve_unstaged,
run_git,
Expand Down Expand Up @@ -281,12 +282,31 @@ def _keyed_imports(items: List[_Import]) -> Dict[Tuple[str, int], _Import]:

def analyze_local_diff(
repository: str = ".", *, staged: bool = False,
pathspecs: Optional[Sequence[str]] = None, wild_version: str = package_version,
pathspecs: Optional[Sequence[str]] = None,
base_ref: Optional[str] = None,
head_ref: Optional[str] = None,
three_dot: bool = False,
wild_version: str = package_version,
) -> Dict:
"""Build a schema-v2 structural artifact for HEAD→index or index→worktree."""
"""Build a schema-v2 artifact for a local snapshot or immutable commit range."""
started = time.monotonic()
root = repository_root(repository)
resolution = (resolve_staged if staged else resolve_unstaged)(repository, pathspecs)
if (base_ref is None) != (head_ref is None):
raise ValueError("base_ref and head_ref must be supplied together")
is_commit_range = base_ref is not None
if is_commit_range and staged:
raise ValueError("staged and commit-range comparisons are mutually exclusive")
resolution = (
resolve_commit_range(
repository,
base_ref,
head_ref,
three_dot=three_dot,
pathspecs=pathspecs,
)
if is_commit_range
else (resolve_staged if staged else resolve_unstaged)(repository, pathspecs)
)
warnings = [_resolution_warning(item) for item in resolution.warnings]
files: List[Dict] = []
symbols: List[Dict] = []
Expand All @@ -299,7 +319,11 @@ def analyze_local_diff(
raise RuntimeError("snapshot entry has neither an old nor a new path")
try:
old = _blob(root, entry.old_oid)
new = _blob(root, entry.new_oid) if staged else _worktree_bytes(root, entry)
new = (
_blob(root, entry.new_oid)
if staged or is_commit_range
else _worktree_bytes(root, entry)
)
except (OSError, GitSnapshotError) as error:
old = new = None
warnings.append(_warning("PARTIAL_ANALYSIS", path, "snapshot read failed: {}".format(error)))
Expand Down Expand Up @@ -466,8 +490,12 @@ def analyze_local_diff(
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"wild_version": wild_version,
"diff_ref": {
"kind": "staged" if staged else "unstaged", "base_ref": "HEAD" if staged else None,
"head_ref": None, "pathspecs": list(pathspecs or []), "repo_root": root,
"kind": "commit_range" if is_commit_range else "staged" if staged else "unstaged",
"base_ref": (
resolution.comparison_base_oid if is_commit_range else "HEAD" if staged else None
),
"head_ref": resolution.head_oid if is_commit_range else None,
"pathspecs": list(pathspecs or []), "repo_root": root,
},
"files": files, "symbols": symbols, "relationships": relationships,
"summary": None,
Expand Down
Loading
Loading