diff --git a/.gitignore b/.gitignore index e48b1e6..2181245 100644 --- a/.gitignore +++ b/.gitignore @@ -192,4 +192,7 @@ cython_debug/ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files .cursorignore -.cursorindexingignore \ No newline at end of file +.cursorindexingignore + +# Default `wild diff -o`/--format html report +diffgraph.html diff --git a/CHANGELOG.md b/CHANGELOG.md index af366b6..372de38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-10-26 + ### Added +- `wild diff REF` compares a commit with the working tree (like + `git diff REF`), and `wild diff --staged REF` with the index. A single + operand that is not a commit is a usage error instead of an empty diff. +- `--llm` tiers: `claude:fast` (Haiku), `claude:balanced`, `gpt:fast`, + `gpt:balanced`, `gemini:fast`, and `:best` (the default flagship). +- HTML graph: "Fit to view" and "Fit width" controls; the wheel zooms + around the cursor, and the graph refits as its panel resizes. - `inherits` edges for bare-name base classes, only to a confirmed class: local, imported from a changed file, or a changed or deleted class subclassed outside the diff. External bases keep only their import symbol. diff --git a/README.md b/README.md index 044c9b8..8ffc8dd 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,9 @@ combinations are usage errors. Likewise, `--format terminal` cannot use `--output`. Terminal-only `--compact` and `--all` flags follow `diff`. Canonical output supports local unstaged (`index` → working tree), staged -(`HEAD` → index), explicit two-dot (`BASE..HEAD`), and explicit three-dot +(`HEAD` → index), one commit against the working tree (`wild diff REF`, like +`git diff REF`, including untracked files) or the index (`wild diff --staged +REF`), 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. For commit ranges, `diff_ref` records both the @@ -117,7 +119,9 @@ 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. +of a commit range are required. A single operand that is not a commit +(e.g. a mistyped ref or a pathspec without `--`) is a usage error, never an +empty diff. Python (`.py`) is the only language with structural symbol/import extraction in this baseline. Other changed files remain in `files[]` and receive a scoped @@ -180,9 +184,13 @@ boxes and lists, terminal `AI:` lines). Requests go through `claude-*`, `gpt-*`, and `gemini-*` models (or an explicit `anthropic/`, `openai/`, `google/` prefix) to that provider. -- **Models:** `--llm claude`, `--llm gpt`, and `--llm gemini` use - `claude-opus-5-5`, `gpt-6-astra`, and `gemini-4-argon`; pass any model id - (e.g. `--llm gemini-3.8-flash`) to choose another. +- **Models:** `--llm claude`, `--llm gpt`, and `--llm gemini` use each + provider's flagship (`claude-opus-5-5`, `gpt-6-astra`, `gemini-4-argon`). + Add a tier for speed or cost: `claude:fast` (`claude-haiku-5-5`), + `claude:balanced` (`claude-sonnet-5-5`), `gpt:fast` (`gpt-6-luna`), + `gpt:balanced` (`gpt-6.1-sol`), `gemini:fast` (`gemini-3.8-flash`); `:best` + is the flagship. Any exact model id also works (e.g. `--llm + gemini-3.8-flash`). - **Keys:** on first use, `wild` registers with the relay and sends the provider key once; the relay stores it encrypted. The key comes from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY`/`GOOGLE_API_KEY` diff --git a/diffgraph/__init__.py b/diffgraph/__init__.py index 402c60b..42c16bb 100644 --- a/diffgraph/__init__.py +++ b/diffgraph/__init__.py @@ -2,4 +2,4 @@ DiffGraph - A CLI tool for visualizing code changes with AI """ -__version__ = "1.2.0" +__version__ = "1.3.0" diff --git a/diffgraph/artifact.py b/diffgraph/artifact.py index 8e5248d..31a5c75 100644 --- a/diffgraph/artifact.py +++ b/diffgraph/artifact.py @@ -25,6 +25,7 @@ def build_validated_artifact( base_ref: Optional[str] = None, head_ref: Optional[str] = None, three_dot: bool = False, + against: Optional[str] = None, wild_version: str, ) -> ValidatedArtifact: """Construct and validate exactly one local structural artifact.""" @@ -35,6 +36,7 @@ def build_validated_artifact( base_ref=base_ref, head_ref=head_ref, three_dot=three_dot, + against=against, wild_version=wild_version, ) return ValidatedArtifact.from_value(artifact) diff --git a/diffgraph/cli.py b/diffgraph/cli.py index 8071ecb..fd766db 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -14,7 +14,7 @@ ) from diffgraph.contract import DiffGraphContractError, validate_artifact from diffgraph.env_loader import load_env_file, debug_environment -from diffgraph.git_snapshot import GitSnapshotError +from diffgraph.git_snapshot import GitSnapshotError, UnknownRevisionError from diffgraph.utils import sanitize_diff_args, involves_working_tree from diffgraph.structural import StructuralDependencyError @@ -185,6 +185,7 @@ class _StructuralScope: base_ref: Optional[str] = None head_ref: Optional[str] = None three_dot: bool = False + against: Optional[str] = None def _range_operand(argument: str): @@ -220,7 +221,7 @@ def _structural_scope( arguments = arguments[:-count] staged = False - base_ref = head_ref = None + base_ref = head_ref = against = None three_dot = False for argument in arguments: if argument in ("--staged", "--cached"): @@ -232,11 +233,15 @@ def _structural_scope( continue commit_range = _range_operand(argument) if commit_range is None: - raise click.UsageError( - "canonical output supports unstaged, --staged/--cached, or explicit " - "BASE..HEAD/BASE...HEAD diffs; put pathspecs after '--'" - ) - if staged or base_ref is not None: + # One commit, like ``git diff REF`` (or ``--staged REF``). + if argument.startswith("-") or against is not None or base_ref is not None: + raise click.UsageError( + "canonical output supports unstaged, --staged/--cached, REF, " + "--staged REF, or BASE..HEAD/BASE...HEAD diffs; put pathspecs after '--'" + ) + against = argument + continue + if staged or base_ref is not None or against is not None: raise click.UsageError("use exactly one local mode or commit range") base_ref, head_ref, three_dot = commit_range @@ -246,6 +251,7 @@ def _structural_scope( base_ref, head_ref, three_dot, + against, ) @@ -457,9 +463,10 @@ def progress(done: int, total: int) -> None: default=None, help=( 'Add AI summaries to each changed file and symbol through BYOK Relay. ' - 'MODEL is a provider (claude, gpt, gemini: its latest flagship) or an ' - 'exact model id (e.g. gemini-3.8-flash). Sends the changed code to ' - 'the model provider.' + 'MODEL is a provider (claude, gpt, gemini: its latest flagship), a ' + 'provider tier (e.g. claude:fast for Haiku; tiers: fast, balanced, ' + 'best), or an exact model id (e.g. gemini-3.8-flash). Sends the ' + 'changed code to the model provider.' ), ) def main( @@ -539,8 +546,11 @@ def main( base_ref=scope.base_ref, head_ref=scope.head_ref, three_dot=scope.three_dot, + against=scope.against, wild_version=__version__, ) + except UnknownRevisionError as error: + raise click.UsageError(str(error)) from error except ( GitSnapshotError, StructuralDependencyError, diff --git a/diffgraph/formatters/html.py b/diffgraph/formatters/html.py index 8768157..ff8dfbd 100644 --- a/diffgraph/formatters/html.py +++ b/diffgraph/formatters/html.py @@ -58,6 +58,10 @@ def _diff_label(diff_ref: dict) -> str: head = diff_ref.get("requested_head_ref") or _short_ref(diff_ref.get("head_ref")) separator = "..." if diff_ref.get("comparison_mode") == "three_dot" else ".." label = f"{base}{separator}{head}" + elif kind == "working_tree": + label = f"{diff_ref.get('requested_base_ref') or _short_ref(diff_ref.get('base_ref'))} → working tree" + elif kind == "staged" and diff_ref.get("requested_base_ref"): + label = f"{diff_ref['requested_base_ref']} → index (staged)" else: label = {"unstaged": "Unstaged changes", "staged": "Staged changes"}.get(kind, str(kind)) pathspecs = diff_ref.get("pathspecs") or [] @@ -87,7 +91,8 @@ def _diff_label(diff_ref: dict) -> str: if (!files.length) return; const svg = document.createElementNS(NS, 'svg'); - svg.setAttribute('viewBox', '0 0 1400 840'); + // The viewBox tracks the host's pixel size, so pan/zoom work in screen + // pixels; the layout itself is LAYOUT_WIDTH units wide. svg.setAttribute('preserveAspectRatio', 'xMinYMin meet'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', 'Interactive DiffGraph visualization'); @@ -136,6 +141,15 @@ def _diff_label(diff_ref: dict) -> str: return { text: summary.text.trim().replace(/\\s+/g, ' '), model: inference && inference.model ? String(inference.model) : null }; }; const truncate = (value, max) => (value.length > max ? value.slice(0, max - 1) + '…' : value); + // Keep the end of a long path (the file name matters most), cut at a '/' + // where possible. Full paths stay in tooltips and the details panel. + const truncatePath = (value, max) => { + const text = String(value); + if (text.length <= max) return text; + const tail = text.slice(text.length - (max - 1)); + const slash = tail.indexOf('/'); + return '…' + (slash > 0 && slash < tail.length - 12 ? tail.slice(slash) : tail); + }; const summaryLine = (summary, max) => truncate(`AI · ${summary.text}`, max); const fileIndex = new Map(files.map((file) => [file.id, file])); @@ -587,10 +601,63 @@ def _diff_label(diff_ref: dict) -> str: } host.appendChild(toolbar); const setTransform = () => viewport.setAttribute('transform', `translate(${state.x} ${state.y}) scale(${state.scale})`); + + // ---- Viewport: fit width by default, fit everything on request, zoom at the cursor ---- + const LAYOUT_WIDTH = 1400; + const MIN_SCALE = 0.1; + const MAX_SCALE = 3; + // Fits may go below the wheel minimum (tall or very narrow graphs). + const FIT_FLOOR = 0.005; + const clampScale = (value) => Math.max(MIN_SCALE, Math.min(MAX_SCALE, value)); + const fitScale = (value) => Math.max(FIT_FLOOR, Math.min(1.25, value)); + const hostSize = () => ({ width: Math.max(1, host.clientWidth), height: Math.max(1, host.clientHeight) }); + // The toolbar wraps on narrow panels; reserve its real height. + const toolbarSpace = () => toolbar.offsetTop + toolbar.offsetHeight + 8; + const syncViewBox = () => { const { width, height } = hostSize(); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); }; + // Drawn bounds, so long paths or names wider than the nominal layout fit too. + const drawnBox = () => { + try { const box = viewport.getBBox(); if (box.width && box.height) return box; } catch (_) { /* not rendered yet */ } + return { x: 0, y: 0, width: LAYOUT_WIDTH, height: 0 }; + }; + const fitWidth = () => { + const { width } = hostSize(); + const box = drawnBox(); + const right = Math.max(LAYOUT_WIDTH, box.x + box.width); + state.scale = fitScale((width - 16) / right); + state.x = 8; + state.y = toolbarSpace(); + setTransform(); + }; + // The scale at which the whole drawn graph fits the panel. + const wholeGraphScale = () => { + const box = drawnBox(); + if (!box.height) return MIN_SCALE; + const { width, height } = hostSize(); + return fitScale(Math.min((width - 24) / box.width, (height - toolbarSpace() - 12) / box.height)); + }; + // Zoom out until the whole graph is visible (or to the wheel minimum, + // whichever is smaller), never further: the graph cannot vanish, and a + // narrow width fit can still zoom out to show everything. + // The floor also respects the current scale so a narrow fitWidth scale + // below MIN_SCALE can still be zoomed out from. + const zoomFloor = () => Math.min(state.scale, MIN_SCALE, wholeGraphScale()); + const fitAll = () => { + const box = drawnBox(); + if (!box.height) { fitWidth(); return; } + const { width } = hostSize(); + const top = toolbarSpace(); + state.scale = wholeGraphScale(); + state.x = (width - box.width * state.scale) / 2 - box.x * state.scale; + state.y = top - box.y * state.scale; + state.userMoved = true; + setTransform(); + }; + tool('Fit to view', fitAll); + tool('Fit width', () => { state.userMoved = false; fitWidth(); }); const draw = () => { viewport.replaceChildren(); const positions = new Map(); - let y = 60; + let y = 8; moduleList.forEach((module) => { const moduleY = y; const group = module.files; @@ -599,7 +666,7 @@ def _diff_label(diff_ref: dict) -> str: const moduleCollapsed = state.collapsedModules.has(module.id); const moduleToggle = svgEl('g', { class: 'module-toggle', tabindex: '0', role: 'button', 'aria-expanded': String(!moduleCollapsed), 'aria-label': `${moduleCollapsed ? 'Expand' : 'Collapse'} module ${module.name}` }); moduleToggle.appendChild(svgEl('rect', { x: 28, y: y + 4, width: 1120, height: 26, rx: 6, class: 'toggle-hit' })); - addText(moduleToggle, `${moduleCollapsed ? '▶' : '▼'} module: ${module.name}`, 34, y + 22, 'module-label'); + addText(moduleToggle, `${moduleCollapsed ? '▶' : '▼'} module: ${truncatePath(module.name, 120)}`, 34, y + 22, 'module-label'); if (moduleCollapsed) { const changedCount = symbols.filter((symbol) => moduleIdOf.get(symbol.file_id) === module.id && changedSymbolIds.has(symbol.id)).length; const outside = group.filter((file) => file.change_kind === 'unchanged').length; @@ -639,7 +706,7 @@ def _diff_label(diff_ref: dict) -> str: const groupNode = svgEl('g', { class: 'file-container', tabindex: '0', role: 'button', 'aria-label': `Details for ${file.path || file.id}` }); const context = contextFileIds.has(file.id); groupNode.appendChild(svgEl('rect', { x: 48, y, width: 1080, height, rx: 10, class: `file-box ${changeClass(file.change_kind)}${context ? ' context' : ''}${isSelected(file.id) ? ' selected' : ''}` })); - addText(groupNode, file.path || file.id, 88, y + 29, 'file-label'); + addText(groupNode, truncatePath(file.path || file.id, file.id === startFileId ? 84 : 96), 88, y + 29, 'file-label'); if (file.id === startFileId) addText(groupNode, '◆ start here', 880, y + 29, 'start-label'); addText(groupNode, context ? 'outside diff' : file.change_kind || 'unknown', context ? 1000 : 1014, y + 29, 'change-label'); if (fileSummary) addText(groupNode, summaryLine(fileSummary, 150), 88, y + 52, 'summary-label'); @@ -660,7 +727,7 @@ def _diff_label(diff_ref: dict) -> str: const boxHeight = summary ? 44 : 28; const node = svgEl('g', { class: 'symbol-node', tabindex: '0', role: 'button', 'aria-label': `Details for ${symbol.kind || 'symbol'} ${symbol.qualified_name || symbol.name || symbol.id}` }); node.appendChild(svgEl('rect', { x: 80, y: sy, width: 620, height: boxHeight, rx: 7, class: `symbol-box ${changeClass(symbol.change_kind)}${isSelected(symbol.id) ? ' selected' : ''}` })); - addText(node, `${symbol.kind || 'symbol'}: ${symbol.qualified_name || symbol.name || symbol.id}`, 94, sy + 19, 'symbol-label'); + addText(node, truncate(`${symbol.kind || 'symbol'}: ${symbol.qualified_name || symbol.name || symbol.id}`, 78), 94, sy + 19, 'symbol-label'); if (summary) addText(node, summaryLine(summary, 84), 94, sy + 36, 'summary-label'); node.appendChild(svgEl('circle', { cx: 678, cy: sy + 14, r: 5, class: `change-dot ${changeClass(symbol.change_kind)}` })); interactive(node, symbol, () => select(symbol.id)); @@ -751,13 +818,35 @@ def _diff_label(diff_ref: dict) -> str: defs.appendChild(marker); edges.insertBefore(defs, edges.firstChild); viewport.appendChild(edges); - setTransform(); + // Expanding or collapsing changes the drawn bounds; keep the fit until + // the reader zooms or pans. + if (state.userMoved) setTransform(); else fitWidth(); }; - svg.addEventListener('wheel', (event) => { event.preventDefault(); const factor = event.deltaY < 0 ? 1.12 : 0.89; state.scale = Math.max(0.35, Math.min(3, state.scale * factor)); setTransform(); }, { passive: false }); + svg.addEventListener('wheel', (event) => { + event.preventDefault(); + // Keep the layout point under the cursor fixed while zooming. + const rect = svg.getBoundingClientRect(); + const px = event.clientX - rect.left; + const py = event.clientY - rect.top; + const factor = event.deltaY < 0 ? 1.12 : 0.89; + const next = Math.max(zoomFloor(), Math.min(MAX_SCALE, state.scale * factor)); + state.x = px - (px - state.x) * (next / state.scale); + state.y = py - (py - state.y) * (next / state.scale); + state.scale = next; + state.userMoved = true; + setTransform(); + }, { passive: false }); svg.addEventListener('pointerdown', (event) => { if (event.target === svg) { state.dragging = true; state.startX = event.clientX - state.x; state.startY = event.clientY - state.y; svg.setPointerCapture(event.pointerId); } }); - svg.addEventListener('pointermove', (event) => { if (state.dragging) { state.x = event.clientX - state.startX; state.y = event.clientY - state.startY; setTransform(); } }); + svg.addEventListener('pointermove', (event) => { if (state.dragging) { state.x = event.clientX - state.startX; state.y = event.clientY - state.startY; state.userMoved = true; setTransform(); } }); svg.addEventListener('pointerup', () => { state.dragging = false; }); + syncViewBox(); + fitWidth(); draw(); + // Resizing (window, details panel, side-by-side diff) keeps the fit + // until the reader zooms or pans themselves. + if (typeof ResizeObserver === 'function') { + new ResizeObserver(() => { syncViewBox(); if (!state.userMoved) fitWidth(); }).observe(host); + } })(); """ ).strip() @@ -924,7 +1013,8 @@ def render(self) -> str: .more-label {{ font-size: 13px; fill: var(--text-muted); }} .more-toggle:hover .more-label, .more-toggle:focus .more-label {{ fill: var(--text-strong); }} .more-toggle:focus > rect {{ stroke: var(--focus); stroke-dasharray: none; }} - .graph-toolbar {{ position: absolute; top: .55rem; left: .6rem; z-index: 2; display: flex; gap: .35rem; }} + .graph-toolbar {{ position: absolute; top: .55rem; left: .6rem; right: .6rem; z-index: 2; display: flex; flex-wrap: wrap; gap: .35rem; pointer-events: none; }} + .graph-toolbar .tool {{ pointer-events: auto; }} .tool {{ padding: .2rem .6rem; border: 1px solid var(--outline); border-radius: .35rem; background: var(--node-bg); color: var(--text); font: inherit; font-size: .78rem; cursor: pointer; }} .tool[aria-pressed="true"] {{ border-color: var(--selected); color: var(--selected); }} .module-toggle {{ cursor: pointer; outline: none; }} diff --git a/diffgraph/formatters/terminal.py b/diffgraph/formatters/terminal.py index ddb2d7b..685c075 100644 --- a/diffgraph/formatters/terminal.py +++ b/diffgraph/formatters/terminal.py @@ -482,7 +482,12 @@ def _write_footer(self, out, color: bool) -> None: "unstaged": "unstaged changes", "staged": "staged changes", } - if diff_kind in diff_context_map: + requested_base = diff_ref.get("requested_base_ref") + if diff_kind == "working_tree": + diff_context = f"{requested_base or diff_ref.get('base_ref', '')} → working tree" + elif diff_kind == "staged" and requested_base: + diff_context = f"{requested_base} → index (staged)" + elif diff_kind in diff_context_map: diff_context = diff_context_map[diff_kind] elif diff_kind == "commit_range": base = diff_ref.get("base_ref", "") diff --git a/diffgraph/git_snapshot.py b/diffgraph/git_snapshot.py index 5e9abc9..92de113 100644 --- a/diffgraph/git_snapshot.py +++ b/diffgraph/git_snapshot.py @@ -75,10 +75,29 @@ class CommitRangeResolution: three_dot: bool +@dataclass(frozen=True) +class RefComparisonResolution: + """Entries comparing one resolved commit with the index or working tree. + + Matches ``git diff `` (working tree, including ordinary untracked + files like an unstaged snapshot) and ``git diff --cached `` (index). + """ + + entries: Tuple[SnapshotEntry, ...] + warnings: Tuple[ResolutionWarning, ...] + base_ref: str + base_oid: str + staged: bool + + class GitSnapshotError(RuntimeError): """A Git or working-tree operation required for exact snapshots failed.""" +class UnknownRevisionError(GitSnapshotError): + """A single diff operand is not a commit (usually a pathspec without ``--``).""" + + @dataclass(frozen=True) class _RawEntry: status: str @@ -118,6 +137,33 @@ def resolve_unstaged( return _resolve(repository, pathspecs, staged=False) +def resolve_against_commit( + repository: str, + ref: str, + *, + staged: bool = False, + pathspecs: Optional[Sequence[str]] = None, +) -> RefComparisonResolution: + """Resolve changes from one commit to the working tree (or the index). + + An operand that is not a commit is an error rather than an empty diff: + pathspecs must follow ``--``, so a mistyped ref must never look like a + snapshot without changes. + """ + + root = repository_root(repository) + warnings: List[ResolutionWarning] = [] + base_oid = _resolve_commit(root, ref, "invalid_base_ref", warnings) + if base_oid is None: + raise UnknownRevisionError( + "unknown revision {!r}; put pathspecs after '--'".format(ref) + ) + resolution = _resolve(repository, pathspecs, staged=staged, base_oid=base_oid) + return RefComparisonResolution( + resolution.entries, resolution.warnings, ref, base_oid, staged + ) + + def resolve_commit_range( repository: str, base_ref: str, @@ -264,7 +310,8 @@ def _is_hex_oid(value: str) -> bool: def _resolve( - repository: str, pathspecs: Optional[Sequence[str]], staged: bool + repository: str, pathspecs: Optional[Sequence[str]], staged: bool, + base_oid: Optional[str] = None, ) -> SnapshotResolution: warnings: List[ResolutionWarning] = [] root = _repository_root(repository, warnings) @@ -277,6 +324,9 @@ def _resolve( command.extend( ["--raw", "-z", "--no-abbrev", "--no-ext-diff", "--find-renames=50%"] ) + if base_oid is not None: + # A full object ID cannot be mistaken for an option or a path. + command.append(base_oid) scoped_pathspecs = _root_relative_pathspecs( repository, root, pathspecs, warnings ) @@ -324,12 +374,55 @@ def _resolve( entries.append(entry) if not staged: - entries.extend(_resolve_untracked(root, scoped_pathspecs, warnings)) + untracked = _resolve_untracked(root, scoped_pathspecs, warnings) + if base_oid is not None: + entries, untracked = _reconcile_untracked_with_base(entries, untracked) + entries.extend(untracked) entries.sort(key=_entry_sort_key) return SnapshotResolution(tuple(entries), tuple(warnings)) +def _reconcile_untracked_with_base( + entries: List[SnapshotEntry], untracked: List[SnapshotEntry] +) -> Tuple[List[SnapshotEntry], List[SnapshotEntry]]: + """Merge a base-commit deletion with an untracked file at the same path. + + After ``git rm --cached``, ``git diff REF`` reports the path as deleted + (it left the index) while the file is still untracked in the working + tree. Against REF that is one file: unchanged, or one modification, never + a deletion plus an addition sharing a ``file::`` id. + """ + deletions = { + entry.old_path: entry + for entry in entries + if entry.new_path is None and entry.old_path is not None + } + merged: List[SnapshotEntry] = [] + replaced = set() + remaining: List[SnapshotEntry] = [] + for addition in untracked: + deletion = deletions.get(addition.new_path) + if deletion is None: + remaining.append(addition) + continue + replaced.add(deletion.old_path) + if deletion.old_oid == addition.new_oid and deletion.old_mode == addition.new_mode: + continue # identical to the base commit: no change + same_type = (deletion.old_mode or "")[:3] == (addition.new_mode or "")[:3] + merged.append(SnapshotEntry( + status="M" if same_type else "T", + old_path=deletion.old_path, + new_path=addition.new_path, + old_mode=deletion.old_mode, + new_mode=addition.new_mode, + old_oid=deletion.old_oid, + new_oid=addition.new_oid, + )) + kept = [entry for entry in entries if entry.new_path is not None or entry.old_path not in replaced] + return kept + merged, remaining + + def _resolve_untracked( root: str, pathspecs: Sequence[str], diff --git a/diffgraph/llm_summaries.py b/diffgraph/llm_summaries.py index f7ab20b..24a58d0 100644 --- a/diffgraph/llm_summaries.py +++ b/diffgraph/llm_summaries.py @@ -113,7 +113,9 @@ def _unified_diff(path: str, old: Optional[str], new: Optional[str]) -> str: def _jobs(dg: dict) -> Tuple[List[_FileJob], List[Dict[str, str]]]: root = dg["diff_ref"].get("repo_root") or "." - worktree = dg["diff_ref"].get("kind") == "unstaged" + # The post-change side lives in the working tree (re-verified against its + # recorded blob id) for unstaged diffs and for ``wild diff REF``. + worktree = dg["diff_ref"].get("kind") in ("unstaged", "working_tree") symbols_by_file: Dict[str, List[dict]] = {} for symbol in dg["symbols"]: if symbol["change_kind"] != "unchanged" and symbol["kind"] != "import": diff --git a/diffgraph/relay.py b/diffgraph/relay.py index f233756..55e1f5c 100644 --- a/diffgraph/relay.py +++ b/diffgraph/relay.py @@ -50,11 +50,15 @@ "openai": "openai", "gpt": "openai", "google": "google", "gemini": "google", } -DEFAULT_MODELS = { - "anthropic": "claude-opus-5-5", - "openai": "gpt-6-astra", - "google": "gemini-4-argon", +# ``--llm :`` picks a speed/cost tier. ``best`` (the bare +# provider default) is the latest coding/reasoning flagship; ``fast`` is the +# cheapest, quickest current model. Update as providers release new models. +MODEL_TIERS = { + "anthropic": {"fast": "claude-haiku-5-5", "balanced": "claude-sonnet-5-5", "best": "claude-opus-5-5"}, + "openai": {"fast": "gpt-6-luna", "balanced": "gpt-6.1-sol", "best": "gpt-6-astra"}, + "google": {"fast": "gemini-3.8-flash", "best": "gemini-4-argon"}, } +DEFAULT_MODELS = {provider: tiers["best"] for provider, tiers in MODEL_TIERS.items()} # Environment variables that may hold a provider key for first-time setup. PROVIDER_KEY_ENVS = { "anthropic": ("ANTHROPIC_API_KEY",), @@ -109,11 +113,23 @@ def provider_for_model(model: str) -> str: def resolve_model(value: str) -> Tuple[str, str]: - """Return ``(provider, model)`` for a provider name or an exact model id.""" + """Return ``(provider, model)`` for ``provider``, ``provider:tier``, or a model id.""" value = value.strip() - provider = PROVIDER_ALIASES.get(value.lower()) - if provider is not None: + name, separator, tier = value.lower().partition(":") + provider = PROVIDER_ALIASES.get(name) + # Only a bare provider selects the flagship: an explicit but empty tier + # (e.g. an unset ``$TIER`` in ``claude:$TIER``) is an error, not Opus. + if provider is not None and not separator: return provider, DEFAULT_MODELS[provider] + if provider is not None: + tiers = MODEL_TIERS[provider] + if tier not in tiers: + raise ValueError( + "unknown tier {!r} for {}; use one of: {}".format( + tier, name, ", ".join(sorted(tiers)) + ) + ) + return provider, tiers[tier] return provider_for_model(value), value diff --git a/diffgraph/schema/diffgraph-v2.schema.json b/diffgraph/schema/diffgraph-v2.schema.json index 84ff2ea..ce5f3f5 100644 --- a/diffgraph/schema/diffgraph-v2.schema.json +++ b/diffgraph/schema/diffgraph-v2.schema.json @@ -36,29 +36,29 @@ "properties": { "kind": { "type": "string", - "enum": ["unstaged", "staged", "commit_range", "file_scope"], - "description": "Maps directly to the `wild diff` variant used." + "enum": ["unstaged", "staged", "commit_range", "working_tree", "file_scope"], + "description": "Maps directly to the `wild diff` variant used: unstaged (index → working tree), staged (HEAD → index, or REF → index for `--staged REF`), commit_range (BASE..HEAD / BASE...HEAD), working_tree (REF → working tree for `wild diff REF`)." }, "base_ref": { "type": ["string", "null"], - "description": "Resolved comparison-base commit object ID; for three_dot ranges this is the resolved merge base. null for working-tree diffs." + "description": "Resolved comparison-base commit object ID: the base commit for commit_range (the merge base for three_dot), working_tree, and `--staged REF`. \"HEAD\" for a plain staged diff; null for unstaged." }, "head_ref": { "type": ["string", "null"], - "description": "Immutable commit object ID for the head side. null for working-tree diffs." + "description": "Immutable commit object ID for the head side of a commit_range. null when the head side is the index or working tree." }, "requested_base_ref": { "type": ["string", "null"], - "description": "User-supplied base ref before immutable resolution. null for working-tree diffs." + "description": "User-supplied base ref before immutable resolution, for commit_range, working_tree, and `--staged REF`. null otherwise." }, "requested_head_ref": { "type": ["string", "null"], - "description": "User-supplied head ref before immutable resolution. null for working-tree diffs." + "description": "User-supplied head ref before immutable resolution, for commit_range only. null otherwise." }, "comparison_mode": { "type": ["string", "null"], "enum": ["two_dot", "three_dot", null], - "description": "Commit-range semantics. three_dot uses the resolved merge-base in base_ref. null for working-tree diffs." + "description": "Commit-range semantics. three_dot uses the resolved merge-base in base_ref. null for every other kind." }, "pathspecs": { "type": "array", diff --git a/diffgraph/schema/diffgraph-v2.structural.example.json b/diffgraph/schema/diffgraph-v2.structural.example.json index a0df859..f5814a0 100644 --- a/diffgraph/schema/diffgraph-v2.structural.example.json +++ b/diffgraph/schema/diffgraph-v2.structural.example.json @@ -3,9 +3,12 @@ "generated_at": "2026-08-12T10:00:00Z", "wild_version": "1.1.0", "diff_ref": { - "kind": "unstaged", - "base_ref": null, + "kind": "working_tree", + "base_ref": "0123456789abcdef0123456789abcdef01234567", "head_ref": null, + "requested_base_ref": "main", + "requested_head_ref": null, + "comparison_mode": null, "pathspecs": [], "repo_root": "/example/repository" }, diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 21456c3..0022e80 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -28,6 +28,7 @@ SnapshotEntry, read_worktree_blob, repository_root, + resolve_against_commit, resolve_commit_range, resolve_staged, resolve_unstaged, @@ -1355,9 +1356,14 @@ def analyze_local_diff( base_ref: Optional[str] = None, head_ref: Optional[str] = None, three_dot: bool = False, + against: Optional[str] = None, wild_version: str = package_version, ) -> Dict: - """Build a schema-v2 artifact for a local snapshot or immutable commit range.""" + """Build a schema-v2 artifact for a local snapshot or immutable commit range. + + ``against`` compares one commit with the working tree (``git diff REF``) + or, with ``staged``, with the index (``git diff --cached REF``). + """ started = time.monotonic() root = repository_root(repository) if (base_ref is None) != (head_ref is None): @@ -1365,8 +1371,12 @@ def analyze_local_diff( is_commit_range = base_ref is not None if is_commit_range and staged: raise ValueError("staged and commit-range comparisons are mutually exclusive") + if is_commit_range and against is not None: + raise ValueError("a commit range and a single base ref are mutually exclusive") resolution = ( - resolve_commit_range( + resolve_against_commit(repository, against, staged=staged, pathspecs=pathspecs) + if against is not None + else resolve_commit_range( repository, base_ref, head_ref, @@ -1814,12 +1824,21 @@ def analyze_local_diff( "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "wild_version": wild_version, "diff_ref": { - "kind": "commit_range" if is_commit_range else "staged" if staged else "unstaged", + "kind": ( + "commit_range" if is_commit_range + else "staged" if staged + else "working_tree" if against is not None + else "unstaged" + ), "base_ref": ( - resolution.comparison_base_oid if is_commit_range else "HEAD" if staged else None + resolution.comparison_base_oid if is_commit_range + else resolution.base_oid if against is not None + else "HEAD" if staged else None ), "head_ref": resolution.head_oid if is_commit_range else None, - "requested_base_ref": resolution.base_ref if is_commit_range else None, + "requested_base_ref": ( + resolution.base_ref if is_commit_range or against is not None else None + ), "requested_head_ref": resolution.head_ref if is_commit_range else None, "comparison_mode": ( ("three_dot" if three_dot else "two_dot") if is_commit_range else None diff --git a/llms.txt b/llms.txt index addea3f..cc9cb4e 100644 --- a/llms.txt +++ b/llms.txt @@ -1,20 +1,29 @@ # DiffGraph-CLI (WildestAI) -> `wild diff` — AI-powered semantic analysis of git diffs. +> `wild diff` — deterministic, offline semantic graphs of git diffs. ## What it does -Wraps `git` and adds an AI-powered `wild diff` command that analyses your git diff -and produces a dependency graph HTML report showing what changed, how components -relate, and what needs attention. +Wraps `git` and adds `wild diff`, which parses the changed Python code with +tree-sitter and builds a graph of changed modules, files, and symbols with +their calls, imports, and inheritance, including callers outside the diff. +It prints a ranked review path in the terminal, writes an interactive offline +HTML report, or emits a schema-validated JSON artifact. The terminal, HTML, +and JSON formats make no network access unless you opt in to AI summaries +with `--llm`. The deprecated `--format legacy-html` is the exception: it +calls OpenAI and loads assets from external CDNs. ## Quick start ```bash -pip install wildest-ai -export OPENAI_API_KEY=sk-... -wild diff # analyse current unstaged changes -wild diff --staged # analyse staged changes +pip install -e . # from a clone of this repository +wild diff # unstaged + untracked changes +wild diff --staged # staged changes +wild diff main # main -> working tree +wild diff main...feature # a branch's changes since it forked +wild diff -o report.html # interactive HTML report +wild diff --format json # canonical JSON artifact +wild diff --llm claude:fast # opt-in AI summaries via BYOK Relay ``` All other `wild ` commands pass through to `git` transparently. diff --git a/setup.py b/setup.py index 8200ed2..0fa7155 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="wild", - version="1.2.0", + version="1.3.0", packages=find_packages(), package_data={ "diffgraph": [ diff --git a/skill.md b/skill.md index ee77d69..223c662 100644 --- a/skill.md +++ b/skill.md @@ -1,6 +1,6 @@ # skill: wild-diff -Use this skill to analyse git diffs with WildestAI's `wild` CLI and get semantic understanding of code changes. +Use this skill to analyse git diffs with WildestAI's `wild` CLI and get a structural understanding of code changes. ## What it does @@ -14,15 +14,15 @@ review path to stdout. Canonical HTML and JSON reports are available explicitly: ## Prerequisites ```bash -# Install -pip install wildest-ai -# or from source: +# Install from source (Python 3.10+) git clone https://github.com/WildestAI/wild && cd wild && pip install -e . - -# Set your OpenAI API key -export OPENAI_API_KEY=sk-... ``` +No API key is needed: the terminal, HTML, and JSON formats are local and +offline. AI summaries are opt-in (`--llm`, see below). The deprecated +`--format legacy-html` is the exception: it sends code to OpenAI and loads +external CDN assets, so avoid it for private code. + ## Commands ```bash @@ -32,20 +32,26 @@ wild diff # Analyse only staged changes wild diff --staged -# Diff between HEAD and a specific commit -wild diff +# Compare a commit (or branch) with the working tree, like `git diff REF` +wild diff HEAD +wild diff main -# Diff between two commits -wild diff +# Compare two commits, or a branch since it forked from main +wild diff HEAD~3..HEAD +wild diff main...feature -# Diff for a specific file -wild diff path/to/file.py +# Limit to paths: put them after `--` +wild diff main -- path/to/file.py -# Generate an interactive HTML report, don't auto-open -wild diff --format html --output report.html --no-open -# `-o` alone also selects HTML +# Machine-readable artifact (schema 2.1) for agents and CI +wild diff --format json main...feature + +# Interactive offline HTML report, don't auto-open (`-o` alone selects HTML) wild diff -o report.html --no-open +# Opt-in one-sentence AI summaries per changed file and symbol +wild diff --llm claude:fast # Haiku; also claude, gpt, gemini, gpt:fast, gemini:fast + # All other git commands pass through transparently wild log wild blame path/to/file.py @@ -76,14 +82,15 @@ The MCP server exposes tools: ## Configuration -Environment variables: -- `OPENAI_API_KEY` — required for AI analysis -- Copy `.env.example` to `.env` to set locally +AI summaries (`--llm`) go through BYOK Relay. Provider keys come from the +shell environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`) +or a hidden prompt; they are never read from a repository's `.env`. +`BYOK_RELAY_TOKEN` / `BYOK_RELAY_URL` (shell only) select an existing relay +token or a self-hosted relay. ## Notes -- Works with Python 3.7+ -- Tested on macOS and Linux +- Requires Python 3.10+; tested on macOS and Linux +- Structural analysis covers Python; other files are listed with their diffs - The CLI wraps `git` — it must be run inside a git repository -- Large diffs may be slow; consider scoping with file paths or commit ranges -- The `.env` file is git-ignored — never commit API keys +- Exit codes: 0 success (including no changes), 1 runtime failure, 2 usage error diff --git a/tests/test_git_snapshot.py b/tests/test_git_snapshot.py index 122bc10..d6d54c0 100644 --- a/tests/test_git_snapshot.py +++ b/tests/test_git_snapshot.py @@ -919,3 +919,25 @@ def test_commit_range_binary_blobs_preserve_exact_endpoint_provenance(tmp_path): assert (entries["assets/removed.bin"].old_oid, entries["assets/removed.bin"].new_oid) == ( oid(repo, "before-binary-change:assets/removed.bin"), None, ) + + +def test_ref_comparison_merges_index_removal_with_untracked_working_file(tmp_path): + """After `git rm --cached`, a file still in the working tree is one + comparison against REF, not a deletion plus an addition of the same path.""" + from diffgraph.git_snapshot import resolve_against_commit + + repo = make_repo(tmp_path) + write(repo, "same.py", b"def f():\n return 1\n") + write(repo, "edited.py", b"def g():\n return 1\n") + commit_all(repo) + git(repo, "rm", "-q", "--cached", "same.py", "edited.py") + write(repo, "edited.py", b"def g():\n return 2\n") + + result = resolve_against_commit(str(repo), "HEAD") + + assert [(entry.status, entry.old_path, entry.new_path) for entry in result.entries] == [ + ("M", "edited.py", "edited.py"), + ] + entry = result.entries[0] + assert entry.old_oid == oid(repo, "HEAD:edited.py") + assert entry.new_oid == git(repo, "hash-object", "edited.py").decode().strip() diff --git a/tests/test_html_formatter.py b/tests/test_html_formatter.py index 862b619..c933011 100644 --- a/tests/test_html_formatter.py +++ b/tests/test_html_formatter.py @@ -469,3 +469,49 @@ def test_details_link_reveals_symbol_inside_a_collapsed_module(): reveal = script.split("const reveal = (id) => {", 1)[1].split("};", 1)[0] assert "state.collapsedModules.delete(moduleIdOf.get(fileId));" in reveal + + +def test_graph_viewport_fits_and_zooms_at_the_cursor(): + from diffgraph.formatters.html import _INTERACTIVE_SCRIPT as script + + assert "tool('Fit to view', fitAll);" in script + assert "tool('Fit width'" in script + # Wheel zoom keeps the point under the cursor fixed. + assert "state.x = px - (px - state.x) * (next / state.scale);" in script + # The viewBox follows the host's pixel size and refits until the user moves. + assert "svg.setAttribute('viewBox', `0 0 ${width} ${height}`)" in script + assert "if (!state.userMoved) fitWidth();" in script + + +def test_viewport_fits_drawn_bounds_and_keeps_the_toolbar_usable(): + from diffgraph.formatters.html import _INTERACTIVE_SCRIPT as script, HtmlFormatter + + # Fit to view may scale below the wheel-zoom minimum so tall graphs fit. + fit_all = script.split("const fitAll = () => {", 1)[1].split("};", 1)[0] + fit_width = script.split("const fitWidth = () => {", 1)[1].split("};", 1)[0] + whole = script.split("const wholeGraphScale = () => {", 1)[1].split("};", 1)[0] + assert "state.scale = wholeGraphScale();" in fit_all and "clampScale" not in fit_all + assert "fitScale(" in whole and "clampScale" not in whole + # Fit width on a very narrow panel may also go below the wheel minimum. + assert "clampScale" not in fit_width and "fitScale(" in fit_width + assert "const fitScale = (value) => Math.max(FIT_FLOOR" in script + # Zoom-out is bounded by the fitted scale (or the wheel minimum). + assert "Math.max(zoomFloor(), Math.min(MAX_SCALE, state.scale * factor))" in script + # The floor also respects the current scale so a narrow fitWidth scale + # below MIN_SCALE can still be zoomed out from. + assert "const zoomFloor = () => Math.min(state.scale, MIN_SCALE, wholeGraphScale());" in script + assert "fittedScale" not in script + # Fit width uses the drawn bounds (long labels), and redraws keep the fit. + assert "const right = Math.max(LAYOUT_WIDTH, box.x + box.width);" in script + assert "if (state.userMoved) setTransform(); else fitWidth();" in script + # The toolbar wraps and the graph reserves its real height. + assert "toolbar.offsetTop + toolbar.offsetHeight" in script + css = HtmlFormatter(ValidatedArtifact.from_value(golden_artifact())).render() + assert "flex-wrap: wrap" in css.split(".graph-toolbar {", 1)[1].split("}", 1)[0] + + +def test_long_graph_labels_are_shortened_keeping_the_file_name(): + from diffgraph.formatters.html import _INTERACTIVE_SCRIPT as script + + assert "truncatePath(file.path || file.id" in script + assert "truncate(`${symbol.kind || 'symbol'}: ${symbol.qualified_name || symbol.name || symbol.id}`, 78)" in script diff --git a/tests/test_llm_summaries.py b/tests/test_llm_summaries.py index 1edb96e..37f13b4 100644 --- a/tests/test_llm_summaries.py +++ b/tests/test_llm_summaries.py @@ -129,12 +129,30 @@ def test_provider_for_model(model, provider): ("Claude", ("anthropic", "claude-opus-5-5")), ("openai", ("openai", "gpt-6-astra")), ("gemini-3.8-flash", ("google", "gemini-3.8-flash")), + ("claude:fast", ("anthropic", "claude-haiku-5-5")), + ("Claude:Balanced", ("anthropic", "claude-sonnet-5-5")), + ("gpt:fast", ("openai", "gpt-6-luna")), + ("gemini:fast", ("google", "gemini-3.8-flash")), + ("gemini:best", ("google", "gemini-4-argon")), ], ) -def test_resolve_model_accepts_provider_defaults_and_exact_ids(value, expected): +def test_resolve_model_accepts_provider_defaults_tiers_and_exact_ids(value, expected): assert resolve_model(value) == expected +def test_resolve_model_rejects_unknown_tiers(): + with pytest.raises(ValueError, match="unknown tier 'balanced' for gemini; use one of: best, fast"): + resolve_model("gemini:balanced") + + +@pytest.mark.parametrize("value", ["claude:", "gpt:", "gemini:", "anthropic: "]) +def test_resolve_model_rejects_an_explicit_empty_tier(value): + # A bare provider is the flagship shorthand; an empty tier must not + # silently become the most expensive model. + with pytest.raises(ValueError, match="unknown tier ''"): + resolve_model(value) + + def test_provider_for_unknown_model_is_rejected(): with pytest.raises(ValueError, match="cannot tell which provider"): provider_for_model("llama-3") @@ -519,3 +537,15 @@ def rejected(self, model, messages, max_tokens=2048): ) def test_cli_llm_usage_errors(args, repo): assert CliRunner().invoke(cli.main, args).exit_code == 2 + + +def test_ref_diff_summaries_read_the_working_tree_side(repo): + """`wild diff REF --llm`: the new side is the working tree, never an + unwritten blob id looked up with git cat-file.""" + artifact = build_validated_artifact(str(repo), against="HEAD", wild_version="test") + assert artifact.value["diff_ref"]["kind"] == "working_tree" + + result = summarize_artifact(artifact, FakeClient(), "claude:fast") + + assert result.warnings == [] + assert {"file::app.py", "file::NOTES.md", "sym::app.py::value"} <= set(result.summaries) diff --git a/tests/test_structural.py b/tests/test_structural.py index fe9c229..6da5555 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -2532,3 +2532,63 @@ def test_inherits_edges_require_a_confirmed_class_target(tmp_path): # The external import is still recorded as an import symbol with evidence. stub = next(item for item in artifact["symbols"] if item["id"] == "sym::pkg/inside.py::import::external") assert stub["kind"] == "import" and stub["evidence"][0]["snippet"] == "from external import Framework" + + +def _ref_repo(tmp_path): + root = repo(tmp_path) + write(root, "app.py", "def one():\n return 1\n") + git(root, "add", ".") + git(root, "commit", "-m", "base") + write(root, "app.py", "def one():\n return 1\n\ndef two():\n return 2\n") + git(root, "commit", "-am", "second") + write(root, "app.py", "def one():\n return 10\n\ndef two():\n return 2\n") + write(root, "new.py", "def fresh():\n return 0\n") # untracked + return root + + +def test_single_ref_compares_that_commit_with_the_working_tree(tmp_path): + root = _ref_repo(tmp_path) + + artifact = analyze_local_diff(str(root), against="HEAD~1") + + assert_valid(artifact) + ref = artifact["diff_ref"] + assert ref["kind"] == "working_tree" and ref["requested_base_ref"] == "HEAD~1" + assert ref["base_ref"] == git(root, "rev-parse", "HEAD~1").strip() + changes = {item["name"]: item["change_kind"] for item in artifact["symbols"] if item["kind"] != "import"} + # Committed (two), uncommitted (one), and untracked (fresh) changes all count. + assert changes == {"one": "modified", "two": "added", "fresh": "added"} + + +def test_staged_single_ref_compares_that_commit_with_the_index(tmp_path): + root = _ref_repo(tmp_path) + git(root, "add", "app.py") + + artifact = analyze_local_diff(str(root), staged=True, against="HEAD~1") + + ref = artifact["diff_ref"] + assert ref["kind"] == "staged" and ref["requested_base_ref"] == "HEAD~1" + names = {item["name"] for item in artifact["symbols"] if item["change_kind"] != "unchanged"} + assert names == {"one", "two"} # untracked new.py is not in the index + + +def test_cli_single_ref_and_unknown_revision(tmp_path, monkeypatch): + from click.testing import CliRunner + import diffgraph.cli as cli + + root = _ref_repo(tmp_path) + monkeypatch.chdir(root) + + ok = CliRunner().invoke(cli.main, ["diff", "--format", "json", "HEAD"]) + assert ok.exit_code == 0, ok.output + assert json.loads(ok.stdout)["diff_ref"]["kind"] == "working_tree" + + terminal = CliRunner().invoke(cli.main, ["diff", "HEAD~1"]) + assert "HEAD~1 → working tree" in terminal.stdout + + typo = CliRunner().invoke(cli.main, ["diff", "mian"]) + assert typo.exit_code == 2 + assert "unknown revision 'mian'; put pathspecs after '--'" in typo.output + + both = CliRunner().invoke(cli.main, ["diff", "HEAD~1..HEAD", "HEAD"]) + assert both.exit_code == 2