Review a git diff in the browser, comment on lines, blocks, or whole files, then copy the comments as a prompt for an agent.
From conda-forge:
pixi global install diffleOr from npm:
npm install -g @moritzwilksch/diffleTo run it without installing anything:
pixi exec diffle working
npx @moritzwilksch/diffle working
nix run github:moritzwilksch/diffle -- workingThe default Nix package includes gh; use #minimal for Diffle alone, or run a
variant with bundled language-server support:
nix run github:moritzwilksch/diffle#minimal -- working
nix run github:moritzwilksch/diffle#web -- working
nix run github:moritzwilksch/diffle#rust -- working
nix run github:moritzwilksch/diffle#python -- working
nix shell nixpkgs#nil github:moritzwilksch/diffle --command diffle -- workingdiffle # HEAD vs worktree, like a bare `git diff`
diffle working # HEAD vs worktree: staged, unstaged, untracked
diffle develop # merge-base(develop, HEAD) vs HEAD: what this branch added
diffle pr 27 # GitHub PR 27, or its url; without a number, this branch's PR
diffle main..feat # any git-diff revspec: <rev> | a..b | a...b | a b
diffle main..worktree # "worktree" names the uncommitted tree on either side
diffle working --no-lsp # skip the language servers for this run
diffle --help # list all commands and flagsdiffle pr needs an authenticated gh. Foreign PR URLs open in a temporary clone, leaving your local repository untouched.
Closing the last browser tab that diffle opened stops the server and prints open comments to stdout. Pass --keep-alive to keep it running, or use --no-open and press Ctrl+C when done.
Comments persist in <git-dir>/diffle/comments.json and never touch the worktree. They follow changed text where possible and become stale when their text leaves the diff. A comment on a whole file (C, or the speech-bubble button in the file header) sits above the file's first line and only goes stale when the file leaves the comparison.
Generated files and files matching auto-viewed globs start collapsed. A file is generated when .gitattributes marks it linguist-generated, as on GitHub, or when its path or header looks generated; linguist-generated=false opts a file out. There are no globs by default; configure them in settings or with diffle config.
The built app supports a path prefix such as https://my-reverse-proxy:8080/diffle/.
Opening /diffle redirects to /diffle/. Configure the proxy to strip that prefix
when forwarding to diffle, including WebSocket upgrades at /diffle/ws. No build
or CLI base-path setting is needed.
Run diffle with --no-open for a proxy-managed session. If the proxy preserves its
public Host or Origin headers, explicitly trust its public origin (without the path).
Some proxies instead rewrite both headers to the upstream address they use for diffle,
such as a Kubernetes service name; then trust that address. A rejected request gets a
403 naming the header and value that failed, so the browser's network tab shows which.
diffle working -H 0.0.0.0 --no-open --allowed-origin https://proxy.exampleThis applies to both HTTP requests and WebSocket upgrades. The proxy can preserve
the public Host or rewrite it to an allowed upstream Host, such as 127.0.0.1:4966.
Other browser origins remain rejected; forwarded headers do not grant trust.
Without this setting, the proxy must use an allowed upstream Host and validate any
browser Origin against its public origin before rewriting it to the upstream origin.
diffle completion --shell <bash|zsh|fish> prints a standalone completion script. Install it once for the current user and start a new shell:
diffle completion --shell bash > ~/.local/share/bash-completion/completions/diffle
diffle completion --shell zsh > ~/.local/share/zsh/site-functions/_diffle # a directory on fpath, before compinit
diffle completion --shell fish > ~/.config/fish/completions/diffle.fishCompletion runs in the shell alone and never starts diffle, so it stays fast — and stays as it was: run the command again after upgrading to pick up new commands and flags. Commands, shorthands, flags, and their fixed values complete; revisions do not, because a static script cannot ask git for them.
j/k: next or previous line (10j/10k: ten lines down or up)J/K: next or previous file]/[: next or previous hunkc: commentC: comment on the whole fileR: resolveV: select a blockv: mark viewed/: search the current fileg/: search changed filesgf: filter filesyy: copy all commentsF: open the full fileCtrl+o: go back
Press ? in the app for the full list.
Both content searches toggle between diff hunks with context and full file contents, remembering that choice independently. Global search stays within changed files.
Use the pull request icon to add one thread or all open threads to a pending GitHub review — a new one, or the pending review already waiting on the pull request. This requires a local, authenticated gh.
Nothing is submitted for you: open the pull request on GitHub and submit the review yourself, so you can edit or drop comments first.
The GitHub button next to the comparison menu (o) shows the repository from your GitHub origin, even without gh. Pull request details load in the background by matching the old and new branches' upstreams; an explicit diffle pr supplies the PR directly. Discovery never changes your comparison.
Review export is available when the comparison matches an open PR's committed diff. Unpushed commits, worktree comparisons, and different base comparisons can show repository or PR information without enabling export. Stale threads are skipped. Threads on a whole file become GitHub file-level comments.
Exported comments carry a hidden thread ID. Adding the same thread again at the same lines leaves its comment alone, or rewrites it when you edited the thread. Other drafts, including exports from older versions without an ID, stay untouched. The button says Added, Updated, or Already added.
If the pending review cannot be read completely, export stops before changing it. Reviews with more than 1,000 threads exceed the lookup limit.
Language-server indexing can take a while, especially in large repositories.
Definitions, references, hover details, and symbol search come from a language server. diffle looks for one on PATH for every language in the diff and starts it for the run:
gdor Command/Ctrl+click: definitiongy: type definitiongA: referencesgs/gS: file or repository symbols
A result outside the diff's files, such as the standard library, site-packages, or an ignored virtualenv, opens read-only: no comments, no further navigation.
diffle lsp prints what each language would get, and what to install for the ones it cannot serve:
python pyrefly lsp
rust not on PATH (tried rust-analyzer)
Languages served out of the box: C/C++ (clangd), Go (gopls), Haskell, Java, JavaScript/TypeScript (typescript-language-server, vtsls), Lua, Nix, OCaml, PHP, Python (pyrefly, ty, basedpyright, pyright, pylsp, jedi), Ruby, Rust (rust-analyzer), shell, Swift, Terraform, Zig.
JSON/JSONC (vscode-json-language-server or vscode-json-languageserver), YAML (yaml-language-server), and TOML (tombi, falling back to taplo) also provide schema descriptions on hover. Install the corresponding server on PATH; common filenames are associated through SchemaStore, and explicit $schema declarations or the server's schema directives can select a schema. Schema downloads require network access; diffle caches the JSON catalog and reuses it offline. As with other LSP features, hover is available on the new side when the review follows the checkout.
Configuration files (JSON/JSONC, YAML, and TOML) offer schema hover with the pointer or gh. Clicking a key selects its line; symbol menus and definition, type-definition, and reference navigation are disabled for these formats.
A client-side Tree-sitter worker suppresses hover and symbol menus on reserved keywords and in comments and string text; identifiers, including keyword spellings used as property names, and interpolated expressions remain actionable. Grammars load on demand. Haskell, Nix, Terraform, files over one million UTF-16 code units, and parser failures fall back to language-server behavior.
Override a command, or turn one language off with an empty command:
diffle config set-lsp rust "rust-analyzer" # persistent
diffle config set-lsp java "" # never start one for java
diffle config unset-lsp rust # back to PATH
diffle working --lsp python="pyrefly lsp" # this run only, repeatable
diffle working --no-lsp # no language server at allOnly the languages diffle finds in the changed files get a server; a language named by --lsp starts whether the diff holds it or not.
For large repositories, raise pyrefly's indexing limit:
diffle config set-lsp python "pyrefly lsp --indexing-mode lazy-blocking --workspace-indexing-limit 20000"For an src/ layout, add this to pyproject.toml:
[tool.pyrefly]
search-path = ["src"]Every language-server command runs through a shell inside the repository and can read anything available to your user — the ones found on PATH as much as the ones you configure. --no-lsp starts none.
npm install
npm run dev -- working
npm test && npm run typecheck && npm run buildnpm run dev builds the client, then starts the server from source. Restart it after client changes; the server always serves dist/client.
See AGENTS.md for repository notes.
This workflow and tool were inspired by difit.