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
13 changes: 7 additions & 6 deletions .claude-plugin/skills/revdiff/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
---
name: revdiff
description: Review diffs, files, and documents with inline annotations in a TUI overlay, or answer questions about revdiff usage, configuration, themes, and keybindings. Opens revdiff in tmux/zellij/kitty/wezterm/cmux/ghostty/iterm2/emacs-vterm, captures annotations, and addresses them. Activates on "revdiff", "review diff", "annotate diff", "git review with revdiff", "interactive diff review", "revdiff all files", "review all files", "browse all files", "revdiff config", "revdiff themes", "revdiff keybindings", "how to configure revdiff", "what themes does revdiff have".
argument-hint: 'optional: git ref(s), "all files", or file path'
description: Review diffs, files, and documents with inline annotations in a TUI overlay, or answer questions about revdiff usage, configuration, themes, and keybindings. Opens revdiff in tmux/zellij/kitty/wezterm/cmux/ghostty/iterm2/emacs-vterm, captures annotations, and addresses them. Works in git, hg, and jj repos (auto-detected). Activates on "revdiff", "review diff", "review changes", "annotate diff", "git review with revdiff", "hg review with revdiff", "review jj change", "interactive diff review", "revdiff all files", "review all files", "browse all files", "revdiff config", "revdiff themes", "revdiff keybindings", "how to configure revdiff", "what themes does revdiff have".
argument-hint: 'optional: ref(s), "all files", or file path'
allowed-tools: [Bash, Read, Edit, Write, Grep, Glob]
---

# revdiff - TUI Diff Review

Review git diffs with inline annotations using revdiff TUI in a terminal overlay.
Review diffs with inline annotations using revdiff TUI in a terminal overlay. Works in git, hg, and jj repos (auto-detected).

## Activation Triggers

- "revdiff", "review diff", "annotate diff"
- "revdiff", "review diff", "review changes", "annotate diff"
- "revdiff HEAD~1", "revdiff main"
- "hg review with revdiff", "review jj change"
- "revdiff all files", "review all files", "browse all files"
- "revdiff all files exclude vendor"

Expand All @@ -32,7 +33,7 @@ If the user says things like "locate my review", "use my latest revdiff annotati
${CLAUDE_SKILL_DIR}/scripts/read-latest-history.sh
```

The script resolves the history dir from `$REVDIFF_HISTORY_DIR` (default `~/.config/revdiff/history`), finds the repo subdir via `git rev-parse --show-toplevel` basename, and prints the newest `.md` file found. Each history file contains a header (path, refs, commit hash), the annotations in `## file:line (type)` format, and the raw git diff for annotated files. See `references/usage.md` "Review History" section for directory layout, stdin/only handling, and override options.
The script resolves the history dir from `$REVDIFF_HISTORY_DIR` (default `~/.config/revdiff/history`), finds the repo subdir via VCS root basename (jj/git/hg), and prints the newest `.md` file found. Each history file contains a header (path, refs, and — when available — a git commit hash), the annotations in `## file:line (type)` format, and the raw git diff for annotated files. The `commit:` line and diff block are captured from git only; in hg/jj repos the diff block will be empty and no commit hash is recorded. See `references/usage.md` "Review History" section for directory layout, stdin/only handling, and override options.

## How It Works

Expand Down Expand Up @@ -66,7 +67,7 @@ If not found, guide installation:
**File review mode**: If `$ARGUMENTS` is a file path (e.g., `docs/plans/feature.md`, `/tmp/notes.txt`):
- Skip ref detection entirely
- Go directly to Step 2 with `--only=<filepath>` (no ref argument)
- Works both inside and outside a git repo — revdiff reads the file from disk as context-only
- Works both inside and outside a VCS repo — revdiff reads the file from disk as context-only

**Ref mode**: If `$ARGUMENTS` contains explicit ref(s) (e.g., `HEAD~1`, `main`, or `main feature` for two-ref diff), use as-is.

Expand Down
12 changes: 6 additions & 6 deletions .claude-plugin/skills/revdiff/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ revdiff --all-files --exclude vendor # browse all files, excluding vendor direct
revdiff --include src # include only src/ files
revdiff --include src --exclude src/vendor # include src/ but exclude src/vendor/
revdiff main --exclude vendor # diff against main, excluding vendor
revdiff --only=/tmp/plan.md # review a file outside a git repo (context-only)
revdiff --only=docs/notes.txt # review a file with no git changes (context-only)
revdiff --only=/tmp/plan.md # review a file outside a repo (context-only)
revdiff --only=docs/notes.txt # review a file with no VCS changes (context-only)
printf '# Plan\n\nBody\n' | revdiff --stdin --stdin-name plan.md # review piped text as markdown
some-command | revdiff --stdin --output /tmp/annotations.txt # annotate generated output
```
Expand Down Expand Up @@ -56,10 +56,10 @@ revdiff main --exclude vendor # normal diff, excluding vendor

## Context-Only File Review

When `--only` specifies a file that has no git changes (or when no git repo exists), revdiff shows the file in context-only mode: all lines displayed without `+`/`-` markers, with full annotation and syntax highlighting support.
When `--only` specifies a file that has no VCS changes (or when no repo exists), revdiff shows the file in context-only mode: all lines displayed without `+`/`-` markers, with full annotation and syntax highlighting support.

- **Inside a git repo**: `--only` files not in the diff are read from disk alongside changed files
- **Outside a git repo**: `--only` is required; files are read directly from disk
- **Inside a repo (git/hg/jj)**: `--only` files not in the diff are read from disk alongside changed files
- **Outside a repo**: `--only` is required; files are read directly from disk

## Scratch-Buffer Review

Expand Down Expand Up @@ -191,7 +191,7 @@ Use `--output` / `-o` flag to write annotations to a file instead of stdout.
When you quit with annotations (`q`), revdiff automatically saves a copy of the review session to `~/.config/revdiff/history/<repo-name>/<timestamp>.md`. This is a safety net — if annotations are lost (process crash, agent fails to capture stdout), the history file preserves them.

Each history file contains:
- Header with path, git refs, and commit hash
- Header with path, refs, and (git only) a short commit hash
- Full annotation output (same format as stdout)
- Raw git diff for annotated files only

Expand Down
218 changes: 163 additions & 55 deletions .claude-plugin/skills/revdiff/scripts/detect-ref.sh
Original file line number Diff line number Diff line change
@@ -1,87 +1,195 @@
#!/usr/bin/env bash
# detect-ref.sh - smart ref detection for revdiff skill.
# outputs structured info about the current git state so the skill can decide
# outputs structured info about the current repo state so the skill can decide
# what ref to use or whether to ask the user.
#
# auto-detects the VCS (jj → git → hg precedence, matching app/diff/vcs.go),
# populates the same set of fields regardless of which VCS backs the repo, and
# applies a shared decision block. the git code path's runtime output is
# byte-identical to the pre-refactor script on any git repo state.
#
# output fields:
# branch: current branch name
# main_branch: detected main/master branch name
# is_main: true/false (whether current branch is main/master)
# has_uncommitted: true/false
# has_staged_only: true/false (changes are staged but nothing unstaged)
# suggested_ref: the ref to use (empty = uncommitted, HEAD~1, main branch name, or --all-files for no-commits repos)
# use_staged: true/false (pass --staged to revdiff)
# has_staged_only: true/false (changes are staged but nothing unstaged; git-only)
# suggested_ref: the ref to use (empty = uncommitted, HEAD~1, main branch name, or --all-files for no-commits git repos)
# use_staged: true/false (pass --staged to revdiff; git-only)
# needs_ask: true/false (whether the skill should ask the user)

set -euo pipefail

branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")

# detect main branch name from remote HEAD, fallback to master/main check
# field defaults — each detect_<vcs> may overwrite these
branch="unknown"
main_branch=""
if remote_head=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null); then
main_branch="${remote_head##refs/remotes/origin/}"
elif git show-ref --verify --quiet refs/heads/master 2>/dev/null; then
main_branch="master"
elif git show-ref --verify --quiet refs/heads/main 2>/dev/null; then
main_branch="main"
fi

is_main="false"
if [ "$branch" = "$main_branch" ]; then
is_main="true"
fi

has_uncommitted="false"
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
has_uncommitted="true"
fi

# distinguish staged-only vs unstaged changes
has_unstaged="false"
if ! git diff --quiet 2>/dev/null; then
has_unstaged="true"
fi
has_staged_only="false"
if [ "$has_uncommitted" = "true" ] && [ "$has_unstaged" = "false" ]; then
if ! git diff --cached --quiet 2>/dev/null; then
has_staged_only="true"
has_commits="true"

detect_git() {
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")

# detect main branch name from remote HEAD, fallback to master/main check
main_branch=""
local remote_head
if remote_head=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null); then
main_branch="${remote_head##refs/remotes/origin/}"
elif git show-ref --verify --quiet refs/heads/master 2>/dev/null; then
main_branch="master"
elif git show-ref --verify --quiet refs/heads/main 2>/dev/null; then
main_branch="main"
fi
fi

# detect no-commits state (fresh repo after git init)
has_commits="true"
if ! git rev-parse HEAD >/dev/null 2>&1; then
has_commits="false"
fi
if [ "$branch" = "$main_branch" ]; then
is_main="true"
fi

# decision logic
suggested_ref=""
needs_ask="false"
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
has_uncommitted="true"
fi

use_staged="false"
if [ "$has_commits" = "false" ]; then
suggested_ref="--all-files" # no commits yet, browse staged files
elif [ "$is_main" = "true" ]; then
if [ "$has_uncommitted" = "true" ]; then
if [ "$has_staged_only" = "true" ]; then
use_staged="true" # staged-only changes on main
# distinguish staged-only vs unstaged changes
local has_unstaged="false"
if ! git diff --quiet 2>/dev/null; then
has_unstaged="true"
fi
if [ "$has_uncommitted" = "true" ] && [ "$has_unstaged" = "false" ]; then
if ! git diff --cached --quiet 2>/dev/null; then
has_staged_only="true"
fi
fi

# detect no-commits state (fresh repo after git init)
if ! git rev-parse HEAD >/dev/null 2>&1; then
has_commits="false"
fi
}

detect_hg() {
branch=$(hg branch 2>/dev/null || echo "unknown")

# hg has no remote HEAD equivalent; "default" is the conventional main branch.
main_branch="default"
if [ "$branch" = "$main_branch" ]; then
is_main="true"
fi

if [ -n "$(hg status 2>/dev/null)" ]; then
has_uncommitted="true"
fi

# detect no-commits state (fresh repo after hg init). `hg log -r .` always
# resolves to null revision on empty repos, so use `all()` — empty means
# no commits yet.
if [ -z "$(hg log -r 'all()' -l 1 -T '.' 2>/dev/null)" ]; then
has_commits="false"
fi
}

# targets jj 0.18+ (spec-stable `jj log -T 'bookmarks'` and `jj diff --summary`).
detect_jj() {
# bookmarks on @; @ is usually anonymous (empty template). strip newlines
# that appear when @ has multiple bookmarks (template emits one per line).
branch=$(jj log -r @ --no-graph -T 'bookmarks' 2>/dev/null | tr '\n' ' ' | sed 's/ *$//')
if [ -z "$branch" ]; then
branch="@"
fi

# detect main bookmark: try main, then master. `jj log -r <name>` exits
# non-zero on unresolvable names.
main_branch=""
for candidate in main master; do
if jj log -r "$candidate" -l 1 --no-graph -T '.' >/dev/null 2>&1; then
main_branch="$candidate"
break
fi
done

if [ -n "$main_branch" ]; then
# "am I on main" = @- (parent of working copy) is the main bookmark
# itself. anonymous feature changes descend from main, so the old
# "nearest ancestor bookmark" check mis-fired for them. compare
# change_ids directly — analogous to git's `[ "$branch" = "$main" ]`.
local main_id parent_id
main_id=$(jj log -r "$main_branch" -l 1 --no-graph -T 'change_id' 2>/dev/null)
parent_id=$(jj log -r @- -l 1 --no-graph -T 'change_id' 2>/dev/null)
if [ -n "$main_id" ] && [ "$main_id" = "$parent_id" ]; then
is_main="true"
fi
suggested_ref="" # uncommitted changes on main
else
suggested_ref="HEAD~1" # last commit on main
# set needs_ask here (not in apply_decision_logic) because the decision
# block would otherwise suggest an empty main_branch ref silently;
# without a main bookmark there's nothing sensible to diff against.
needs_ask="true"
fi
else
if [ "$has_uncommitted" = "true" ]; then
needs_ask="true" # ambiguous: uncommitted on feature branch
if [ "$has_staged_only" = "true" ]; then
use_staged="true"

# uncommitted = @ has changes vs @-; empty `jj diff --summary` = no changes.
if [ -n "$(jj diff -r @ --summary 2>/dev/null)" ]; then
has_uncommitted="true"
fi
# jj has no staging area; @ always exists so has_commits stays true.
}

apply_decision_logic() {
# no-commits short-circuit fires first: on git, fall back to --all-files
# (browses staged files); on hg, ask the user since --all-files is not
# supported for hg. jj always has @ so this branch is unreachable for jj.
# short-circuit deliberately precedes is_main/has_uncommitted so a fresh
# hg repo with `?` untracked files doesn't misroute into the main+uncommitted arm.
if [ "$has_commits" = "false" ]; then
if [ "$vcs" = "git" ]; then
suggested_ref="--all-files"
else
needs_ask="true"
fi
elif [ "$is_main" = "true" ]; then
if [ "$has_uncommitted" = "true" ]; then
if [ "$has_staged_only" = "true" ]; then
use_staged="true" # staged-only changes on main
fi
suggested_ref="" # uncommitted changes on main
else
suggested_ref="HEAD~1" # last commit on main
fi
else
suggested_ref="$main_branch" # clean feature branch → diff against main
if [ "$has_uncommitted" = "true" ]; then
needs_ask="true" # ambiguous: uncommitted on feature branch
if [ "$has_staged_only" = "true" ]; then
use_staged="true"
fi
else
suggested_ref="$main_branch" # clean feature branch → diff against main
fi
fi
}

# top-level VCS probe — order matches app/diff/vcs.go (jj first so it wins
# when colocated with .git). command -v guards short-circuit away subprocess
# spawns and "command not found" noise on the common git-only path.
vcs="unknown"
if command -v jj >/dev/null 2>&1 && jj root >/dev/null 2>&1; then
vcs="jj"
elif command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
vcs="git"
elif command -v hg >/dev/null 2>&1 && hg root >/dev/null 2>&1; then
vcs="hg"
fi

suggested_ref=""
needs_ask="false"
use_staged="false"

case "$vcs" in
git) detect_git ;;
hg) detect_hg ;;
jj) detect_jj ;;
*) needs_ask="true" ;; # no VCS detected — defaults already set
esac

apply_decision_logic

echo "branch: $branch"
echo "main_branch: $main_branch"
echo "is_main: $is_main"
Expand Down
20 changes: 17 additions & 3 deletions .claude-plugin/skills/revdiff/scripts/read-latest-history.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
# (temp file cleaned up, or user ran revdiff outside the plugin flow).
#
# resolves the history dir from $REVDIFF_HISTORY_DIR, falling back to
# ~/.config/revdiff/history. resolves the repo subdir from `git rev-parse
# --show-toplevel` basename, falling back to the cwd basename.
# ~/.config/revdiff/history. resolves the repo subdir from the VCS root
# basename, probing jj -> git -> hg (matching DetectVCS precedence in
# app/diff/vcs.go), falling back to the cwd basename when no VCS is detected.
#
# prints file contents if found, prints nothing if not. exits 0 in both cases.

Expand All @@ -17,7 +18,20 @@ if [ -z "$hist_dir" ]; then
hist_dir="${HOME:-}/.config/revdiff/history"
fi

repo="$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")"
repo_root=""
if command -v jj >/dev/null 2>&1; then
repo_root=$(jj root 2>/dev/null || true)
fi
if [ -z "$repo_root" ] && command -v git >/dev/null 2>&1; then
repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
fi
if [ -z "$repo_root" ] && command -v hg >/dev/null 2>&1; then
repo_root=$(hg root 2>/dev/null || true)
fi
if [ -z "$repo_root" ]; then
repo_root="$(pwd)"
fi
repo="$(basename "$repo_root")"
repo_dir="$hist_dir/$repo"

# find newest .md via -nt comparison instead of `ls -t` (shellcheck SC2012,
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ TUI for reviewing diffs, files, and documents with inline annotations, built wit
- No plugin manifest or marketplace envelope — the `.codex-plugin/` / `.agents/` layout was non-conformant with codex's actual format and removed
- Script path resolution in SKILL.md falls back to `${CODEX_HOME:-$HOME/.codex}/skills/<skill>/scripts` when not running inside the revdiff repo
- Scripts are copies from `.claude-plugin/skills/revdiff/scripts/`, not symlinks — each has a source comment at top
- `detect-ref.sh` dispatches by VCS (`detect_git` / `detect_hg` / `detect_jj`) via `command -v` probes (jj → git → hg, matching `DetectVCS` precedence); git path stays byte-identical to the pre-refactor output. `read-latest-history.sh` uses the same VCS probe order for repo-root resolution.
- Codex has no hook system — plan review is manual via `/revdiff-plan`

## Pi Plugin
Expand Down
Loading
Loading