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
19 changes: 19 additions & 0 deletions .claude-plugin/skills/revdiff/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ The script resolves the history dir from `$REVDIFF_HISTORY_DIR` (default `~/.con

When the user asks to open an in-session review in revdiff (the conversation already contains review comments produced earlier in the session), write those comments to a temp file (e.g. `/tmp/revdiff-review-XXXXXX.md`) using the format documented in `references/usage.md` ("Output Format" section), then run the normal launcher flow (Step 1 ref detection, Step 2 invocation) with `--annotations=<temp-path>` appended. Step 3 onward handles the curated annotations as usual.

## Reviewing a Diff That Lives Outside the Working Tree

Some review targets are not the current repo state: a GitHub PR diff, a patch file on disk, or `git format-patch -1 --stdout` output. Pipe the unified diff into `revdiff --stdin` and the input is parsed as a real multi-file diff (one tree entry per file, hunk navigation, per-file annotations) instead of a context-only buffer. revdiff auto-detects the unified-diff signature; on a malformed patch the input falls back silently to raw-text mode.

Use this instead of the normal launcher flow when:
- the user asks to "review PR #N", "review this patch", "review `gh pr diff` output", or supplies a patch URL/path
- the diff describes commits that are not checked out locally (e.g. someone else's branch on a remote-only PR)
- the user pastes a unified diff and asks for a review of *that diff*, not the working tree

Example invocations (route through the same launcher resolver as the normal flow):

```bash
gh pr diff 123 | "$("${CLAUDE_SKILL_DIR}/scripts/resolve-launcher.sh" launch-revdiff.sh "${CLAUDE_PLUGIN_DATA}")" --stdin
git format-patch -1 --stdout | "$("${CLAUDE_SKILL_DIR}/scripts/resolve-launcher.sh" launch-revdiff.sh "${CLAUDE_PLUGIN_DATA}")" --stdin
cat /tmp/feature.patch | "$("${CLAUDE_SKILL_DIR}/scripts/resolve-launcher.sh" launch-revdiff.sh "${CLAUDE_PLUGIN_DATA}")" --stdin
```

`--stdin` is mutually exclusive with refs, `--staged`, `--only`, `--all-files`, `--include`, `--exclude`, and `--annotations`, so do not combine with the Step 1 ref detection — go directly to Step 3 once the launcher returns. Annotations come back keyed by the real file paths from the diff (not by `--stdin-name`).

## How It Works

1. Launch revdiff in a terminal overlay (tmux popup, Zellij floating pane, kitty overlay, wezterm/Kaku split-pane, cmux split, ghostty split+zoom, iTerm2 split pane, or Emacs vterm frame)
Expand Down
10 changes: 8 additions & 2 deletions .claude-plugin/skills/revdiff/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,17 @@ revdiff --compare-old=a.txt --compare-new=b.txt

## Scratch-Buffer Review

Use `--stdin` to review arbitrary piped or redirected text as one synthetic file. All lines are treated as context, so single-file mode, inline annotations, file-level notes, search, wrap, collapsed mode, and structured output all work unchanged.
Use `--stdin` to review arbitrary piped or redirected text. revdiff sniffs the input for a git unified-diff signature: when a line beginning with `diff --git a/` is found near the start, the input is parsed as a real multi-file diff (one tree entry per file, with `+`/`-` markers, hunk navigation, word-diff, compact mode, per-file annotations). Otherwise the input is shown as a single context-only buffer — single-file mode, inline annotations, file-level notes, search, wrap, collapsed mode, and structured output all work unchanged.

- `--stdin` is explicit and requires piped or redirected input
- `--stdin-name` sets the synthetic filename used in annotations and syntax highlighting
- `--stdin-name` sets the synthetic filename used by the context-only buffer (ignored in multi-file diff mode, where real paths are shown)
- `--stdin` conflicts with refs, `--staged`, `--only`, `--all-files`, `--include`, and `--exclude`
- Any per-section parse failure falls the whole input back to raw-text mode so a malformed patch never silently drops files
- Input is capped at 64 MiB

Examples piping a real diff:
- `gh pr diff 123 | revdiff --stdin` — review a GitHub PR end-to-end
- `git format-patch -1 --stdout | revdiff --stdin` — review the latest commit as a multi-file diff

## Key Bindings

Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Built for a specific use case: reviewing code changes, plans, and documents with
- Markdown TOC navigation: single-file markdown files in context-only mode show a table-of-contents pane with header navigation and active section tracking
- All-files mode: browse and annotate all tracked files with `--all-files` (git `ls-files` or jj `file list`), filter with `--include` and `--exclude`
- No-VCS file review: `--only` files outside a VCS repo (or not in any diff) are shown as context-only with full annotation support
- Scratch-buffer review: annotate arbitrary piped or redirected text with `--stdin`, optionally naming it with `--stdin-name`
- Scratch-buffer review: annotate arbitrary piped or redirected text with `--stdin`, optionally naming it with `--stdin-name`. When the piped content sniffs as a git unified diff, revdiff parses it as a real multi-file diff (review `gh pr diff` or `git format-patch -1 --stdout` output directly); otherwise the input is shown as a single context-only buffer.
- Pi package: launch revdiff from pi, capture annotations, and send them to the agent immediately for the normal review loop
- Review history: auto-saves annotations and diffs to `~/.config/revdiff/history/` on quit as a safety net
- Fully customizable colors via environment variables, CLI flags, or config file
Expand Down Expand Up @@ -589,16 +589,20 @@ revdiff --compare-old=a.txt --compare-new=b.txt

### Scratch-Buffer Review

Use `--stdin` to review arbitrary piped or redirected text as a single synthetic file. All lines are shown as context, so the normal single-file review flow still works: annotations, file-level notes, search, wrap, collapsed mode, and structured output.
Use `--stdin` to review arbitrary piped or redirected text. revdiff sniffs the input for a git unified-diff signature: when a line beginning with `diff --git a/` is found near the start, the input is parsed as a real multi-file diff (one tree entry per file, with `+`/`-` markers, hunk navigation, word-diff, compact mode, and per-file annotations); otherwise the input is shown as a single context-only buffer with all lines as context, supporting annotations, file-level notes, search, wrap, collapsed mode, and structured output. Any per-section parse failure falls the whole input back to raw-text mode so a malformed patch never silently drops files. Input is capped at 64 MiB.

`--stdin` is explicit and mutually exclusive with refs, `--staged`, `--only`, `--all-files`, `--include`, `--exclude`, and `--annotations`. stdin mode requires piped or redirected input; plain terminal stdin is rejected to avoid accidentally launching an empty scratch buffer.

Use `--stdin-name` to control the synthetic filename. This gives annotation output a stable key and enables filename-based syntax highlighting or markdown TOC activation:
Use `--stdin-name` to control the synthetic filename for the context-only case (it is ignored in multi-file diff mode, where the tree shows the real paths). This gives annotation output a stable key and enables filename-based syntax highlighting or markdown TOC activation:

```bash
echo "plain text" | revdiff --stdin
printf '# Plan\n\nBody\n' | revdiff --stdin --stdin-name plan.md
git show HEAD~1:README.md | revdiff --stdin --stdin-name README.md

# multi-file diff parsing — tree shows real paths, per-file annotations
gh pr diff 123 | revdiff --stdin
git format-patch -1 --stdout | revdiff --stdin
```

### Review Description
Expand Down
197 changes: 197 additions & 0 deletions app/diff/multiparse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package diff

import (
"errors"
"fmt"
"regexp"
"strings"
)

// rawFileSection holds a raw diff section before parsing.
type rawFileSection struct {
path string // new-side path, extracted by parseFileHeader
status FileStatus // derived from mode/rename headers
diffText string // full section text to pass to parseUnifiedDiff
}

// unifiedDiffSniffLimit caps how many leading bytes isUnifiedDiff inspects.
// 4 KiB comfortably covers `git diff` output (the boundary marker is the very
// first line) but can miss `git format-patch` output when a long commit body
// or large mail header pushes the first `diff --git` past this window — those
// inputs fall back to raw-text rendering. Raising the cap trades worst-case
// sniff cost for broader format-patch coverage; pick a larger value if that
// tradeoff changes.
const unifiedDiffSniffLimit = 4096

// isUnifiedDiff reports whether the content looks like a git unified diff.
// A line in the leading sniff window must START with "diff --git a/" (or the
// quoted form "diff --git \"a/" used by git for paths containing spaces). The
// previous substring check falsely classified prose that merely mentioned the
// marker (e.g. a markdown file documenting diff output). No "@@ -" fallback:
// revdiff only knows how to split sections by "diff --git" boundaries, so
// hunk-only input would mis-render anyway.
//
// Only the first unifiedDiffSniffLimit bytes are inspected — see the const
// comment for the format-patch caveat.
func isUnifiedDiff(content string) bool {
if content == "" {
return false
}

// inspect only the leading bytes for efficiency
sample := content[:min(unifiedDiffSniffLimit, len(content))]

for line := range strings.SplitSeq(sample, "\n") {
if strings.HasPrefix(line, "diff --git a/") || strings.HasPrefix(line, `diff --git "a/`) {
return true
}
}
return false
}

// diffGitHeaderRe matches "diff --git a/path b/path", including quoted paths with spaces.
// Handles git's path forms:
// - diff --git a/path b/path (simple)
// - diff --git "a/path with spaces" "b/path with spaces" (quoted whole path)
// - diff --git a/"path with spaces" b/"path with spaces" (quoted path after prefix)
var diffGitHeaderRe = regexp.MustCompile(`^diff --git ("?a/[^"]*"?|"?a/.*?") ("?b/[^"]*"?|"?b/.*?")`)

// splitMultiFileDiff splits a multi-file unified diff into per-file sections.
// it handles git format: "diff --git a/path b/path".
// each section includes the full diff header through to the next file boundary
// (next "diff --git" or end of input). path and status are resolved once per
// section by parseFileHeader — there is no separate inline header parse.
//
// A section whose header yields no parseable new-side path fails the whole
// call so the caller can fall back to raw-text mode. Silently skipping the
// section would let a single crafted "diff --git" line followed by prose
// drop real content from the rendering with no in-TUI signal.
func splitMultiFileDiff(raw string) ([]rawFileSection, error) {
if raw == "" {
return nil, errors.New("empty input")
}

var sections []rawFileSection
var current strings.Builder
inSection := false

// flush resolves path/status from the accumulated section text and appends
// it. An empty path is a hard failure: the caller falls back to raw-text mode.
flush := func() error {
if !inSection {
return nil
}
text := current.String()
path, status := parseFileHeader(text)
if path == "" {
return fmt.Errorf("section %q has no parseable new-side path", firstLine(text))
}
sections = append(sections, rawFileSection{path: path, status: status, diffText: text})
return nil
}

for line := range strings.SplitSeq(raw, "\n") {
if strings.HasPrefix(line, "diff --git ") {
// new file boundary: flush the previous section and start a fresh one
if err := flush(); err != nil {
return nil, err
}
current.Reset()
inSection = true
}
if inSection {
current.WriteString(line)
current.WriteString("\n")
}
}
if err := flush(); err != nil {
return nil, err
}

if len(sections) == 0 {
return nil, errors.New("no file sections found")
}

return sections, nil
}

// firstLine returns the first line of s, used to identify a malformed section
// in error messages without dumping the whole diff text into the log.
func firstLine(s string) string {
first, _, _ := strings.Cut(s, "\n")
return first
}

// cleanPath strips a leading "a/" or "b/" prefix and surrounding quotes from a path.
// Handles both git path forms:
// - "b/path with spaces" — quotes wrap the prefix; strip quotes first, then prefix
// - b/"path with spaces" — prefix wraps the quotes; strip prefix first, then quotes
//
// Strips the prefix exactly once so a legitimate top-level directory literally
// named "a" or "b" (e.g. "b/b/weird.go" representing repo path b/weird.go)
// resolves to "b/weird.go", not "weird.go".
func cleanPath(path string) string {
// outer-quotes form: "a/foo" or "b/foo"
if len(path) >= 2 && path[0] == '"' && path[len(path)-1] == '"' {
path = path[1 : len(path)-1]
}
switch {
case strings.HasPrefix(path, "a/"):
path = path[2:]
case strings.HasPrefix(path, "b/"):
path = path[2:]
}
// inner-quotes form: prefix already stripped, surviving quotes wrap the body
return strings.Trim(path, `"`)
}

// parseFileHeader extracts the new-side path and change status from a diff
// section's header lines. it parses:
// - "diff --git a/old b/new" → path
// - "new file mode" → FileAdded
// - "deleted file mode" → FileDeleted
// - "rename from/to" → FileRenamed (uses the new path)
//
// status defaults to FileModified.
func parseFileHeader(section string) (path string, status FileStatus) {
status = FileModified

for line := range strings.SplitSeq(section, "\n") {
// header ends at the first hunk
if strings.HasPrefix(line, "@@") {
break
}

switch {
case strings.HasPrefix(line, "new file mode"):
status = FileAdded
case strings.HasPrefix(line, "deleted file mode"):
status = FileDeleted
case strings.HasPrefix(line, "rename from"):
status = FileRenamed
case strings.HasPrefix(line, "diff --git "):
if m := diffGitHeaderRe.FindStringSubmatch(line); len(m) >= 3 {
// use the b-side (new) path
path = cleanPath(m[2])
} else if rest := strings.TrimPrefix(line, "diff --git "); rest != "" {
// fallback for paths the regex cannot split: take the last field as b-path
if parts := strings.Fields(rest); len(parts) >= 2 {
path = cleanPath(parts[len(parts)-1])
}
}
case strings.HasPrefix(line, "rename to "):
// rename target overrides the diff --git path; route through cleanPath
// so quoted paths shed their quotes like every other branch does.
if rest := strings.TrimSpace(strings.TrimPrefix(line, "rename to ")); rest != "" {
path = cleanPath(rest)
}
case strings.HasPrefix(line, "+++ "):
// fallback: derive the path from the +++ line when the header lacked one
if path == "" {
path = cleanPath(strings.TrimSpace(strings.TrimPrefix(line, "+++ ")))
}
}
}

return path, status
}
Loading