mod claude;
mod diff;
mod gh;
mod highlight;
mod list;
mod lsp;
mod md;
mod theme;
mod view;
use anyhow::Context;
use std::path::{Path, PathBuf};
const USAGE: &str = "usage: dv [base | PR | list] [-p N] [-f] [-w [-b]]
dv diff working tree vs merge-base of the PR's base (or origin/HEAD)
dv main ... vs merge-base of main
dv 123 review PR 123 (gh pr checkout 123 first); same as -p 123 / --pr 123
dv list pick from the open PRs; Enter reviews one with the flags given here
(dv list -wb: each in a worktree on its branch), q goes back to the list
dv review-info for Claude Code (or any tool) writing a review dv shows: the review
file's path, and the base and head of the diff dv shows (of PR N with -p N)
dv review-check check that file's comments against the diff (exit 1 on errors)
dv install-skill install the dv-review skill for Claude Code (~/.claude/skills/dv-review)
-f, --force with a PR: reset the local PR branch to the PR head (drops its local commits)
-w, --worktree with a PR: check it out in a worktree next to the repo, <repo>-123
(created or updated, detached at the PR head), and review it there
-b, --branch with -w: check out the PR's branch there instead (tracking it, so you can
push to the PR); git won't if that branch is checked out elsewhere
--rm remove the PR's worktree (dv 123 --rm); -f if it has local changes.
A -b branch is kept, in case it has unpushed commits
short flags combine: dv 123 -wb (worktree on the PR's branch)";
pub(crate) fn progress(msg: Option<&str>) {
use std::io::IsTerminal;
if std::io::stderr().is_terminal() {
match msg {
Some(m) => eprint!("\r\x1b[2Kdv: {m}…"),
None => eprint!("\r\x1b[2K"),
}
}
}
#[derive(Debug, Default, PartialEq)]
struct Opts {
base: Option<String>,
pr: Option<u64>,
force: bool,
worktree: bool,
branch: bool,
rm: bool,
list: bool,
cmd: Option<&'static str>,
help: bool,
}
impl Opts {
fn flags(&self) -> String {
let on: String = [(self.worktree, 'w'), (self.branch, 'b'), (self.force, 'f')].iter().filter(|f| f.0).map(|f| f.1).collect();
if on.is_empty() { on } else { format!("-{on}") }
}
}
fn parse_args(args: impl IntoIterator<Item = String>) -> anyhow::Result<Opts> {
let mut expanded = vec![];
for a in args {
match a.split_once('=') {
Some((flag, v)) if a.starts_with("--") => expanded.extend([flag.to_owned(), v.to_owned()]),
_ if a.starts_with('-') && !a.starts_with("--") && a.len() > 2 => {
expanded.extend(a[1..].chars().map(|c| format!("-{c}")));
}
_ => expanded.push(a),
}
}
let mut o = Opts::default();
let mut args = expanded.into_iter();
let num = |v: Option<String>| -> anyhow::Result<u64> { v.context("--pr needs a number")?.parse().context("--pr needs a number") };
while let Some(a) = args.next() {
match a.as_str() {
"-h" | "--help" => o.help = true,
"-p" | "--pr" => o.pr = Some(num(args.next())?),
"-f" | "--force" => o.force = true,
"-w" | "--worktree" => o.worktree = true,
"-b" | "--branch" => o.branch = true,
"--rm" | "--remove" => o.rm = true,
"list" => o.list = true,
"review-info" => o.cmd = Some("review-info"),
"review-check" => o.cmd = Some("review-check"),
"install-skill" => o.cmd = Some("install-skill"),
_ if a.starts_with('-') => anyhow::bail!("unknown flag {a}\n{USAGE}"),
_ if !a.is_empty() && a.bytes().all(|b| b.is_ascii_digit()) => o.pr = Some(num(Some(a))?),
_ => o.base = Some(a),
}
}
Ok(o)
}
fn main() -> anyhow::Result<()> {
let res = run();
progress(None); res
}
fn run() -> anyhow::Result<()> {
let o = parse_args(std::env::args().skip(1))?;
if o.help {
println!("{USAGE}");
return Ok(());
}
if (o.list || o.cmd.is_some()) && o.rm {
anyhow::bail!("--rm goes with a PR number, e.g. `dv 123 --rm`\n{USAGE}");
}
if o.list && o.pr.is_some() {
anyhow::bail!("dv list picks the PR itself\n{USAGE}");
}
if o.rm {
let n = o.pr.context("--rm needs a PR, e.g. `dv 123 --rm`")?;
return remove_worktree(n, o.force);
}
let root = PathBuf::from(diff::git(&["rev-parse", "--show-toplevel"])?.trim());
std::env::set_current_dir(&root)?;
match o.cmd {
Some("install-skill") => return install_skill(),
Some(cmd) => return review_file(cmd, &o),
None => {}
}
if o.branch && !o.worktree {
anyhow::bail!("--branch goes with --worktree (plain --pr already checks out a branch)\n{USAGE}");
}
if o.list {
return pick_and_review(&o, &root);
}
if o.worktree && o.pr.is_none() {
anyhow::bail!("--worktree needs a PR, e.g. `dv 123 -w`\n{USAGE}");
}
let (wt, res) = review(&o, o.pr)?;
if let Some(dir) = wt {
match diff::git(&["branch", "--show-current"]).map(|b| b.trim().to_owned()) {
Ok(b) if !b.is_empty() => eprintln!("PR checked out in {} on branch {b}", dir.display()),
_ => eprintln!("PR checked out in {}", dir.display()),
}
}
res
}
fn pick_and_review(o: &Opts, root: &Path) -> anyhow::Result<()> {
let mut list = list::List::new(o.flags());
loop {
let mut term = ratatui::init();
let picked = list.run(&mut term);
ratatui::restore();
let Some(pick) = picked? else { return Ok(()) };
let n = pick.number;
let wt;
let opts = match pick.worktree {
Some(branch) => {
wt = Opts { worktree: true, branch, force: false, base: o.base.clone(), ..Opts::default() };
&wt
}
None => o,
};
let res = review(opts, Some(n)).and_then(|(_, res)| res);
progress(None);
std::env::set_current_dir(root)?; if let Err(e) = res {
list.msg = format!("PR #{n}: {e:#}");
}
}
}
fn review(o: &Opts, pr_num: Option<u64>) -> anyhow::Result<(Option<PathBuf>, anyhow::Result<()>)> {
let wt = match (pr_num, o.worktree) {
(Some(n), true) => Some(enter_worktree(n, o.branch, o.force)?),
(Some(n), false) => {
progress(Some(&format!("checking out PR #{n}")));
gh::checkout(n, o.force, false)?;
None
}
(None, _) => None,
};
progress(Some("looking up the PR"));
let pr = gh::pr(pr_num).ok();
let base = diff_base(o, pr.as_ref())?;
let app = view::App::new(base, pr);
progress(None);
let mut app = app?;
let mut term = ratatui::init();
let res = app.run(&mut term);
ratatui::restore();
Ok((wt, res))
}
fn diff_base(o: &Opts, pr: Option<&gh::Pr>) -> anyhow::Result<String> {
let mut base = o.base.clone();
if let (Some(p), None) = (pr, &base) {
progress(Some(&format!("fetching origin/{}", p.base)));
_ = diff::git(&["fetch", "-q", "origin", &p.base]);
base = Some(format!("origin/{}", p.base));
}
diff::merge_base(base.as_deref())
}
fn review_file(cmd: &str, o: &Opts) -> anyhow::Result<()> {
let pr = gh::pr(o.pr).ok();
let path = claude::path(pr.as_ref().map(|p| p.number))?;
let base = diff_base(o, pr.as_ref())?;
if cmd == "review-info" {
let head = diff::git(&["rev-parse", "HEAD"])?;
println!("file: {}", path.display());
println!("base: {base} (`git diff {base}` is the diff dv shows: base → working tree)");
println!("head: {}", head.trim());
match &pr {
Some(p) => println!("pr: #{} {}", p.number, p.title),
None => println!("pr: none (a local review of this branch)"),
}
println!("exists: {}", path.exists());
return Ok(());
}
let files = diff::load(&base, 3)?;
let r = claude::check(&path, &files)?;
for (kind, msgs) in [("fixed", &r.notes), ("warning", &r.warnings), ("error", &r.errors)] {
msgs.iter().for_each(|m| println!("{kind}: {m}"));
}
if !r.errors.is_empty() {
anyhow::bail!("{} of {} comment(s) can't be placed", r.errors.len(), r.comments);
}
println!("ok: {} comment(s), {} warning(s)", r.comments, r.warnings.len());
Ok(())
}
const SKILL: &str = include_str!("../skills/dv-review/SKILL.md");
fn install_skill() -> anyhow::Result<()> {
let home = std::env::var_os("HOME").context("no $HOME")?;
let dir = PathBuf::from(home).join(".claude/skills/dv-review");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("SKILL.md"), SKILL)?;
println!("installed {}", dir.join("SKILL.md").display());
Ok(())
}
fn enter_worktree(n: u64, branch: bool, force: bool) -> anyhow::Result<std::path::PathBuf> {
let (_, dir) = worktree_dir(n)?;
if dir.exists() {
ensure_ours(&dir)?;
} else {
progress(Some(&format!("creating the worktree {}", dir.display())));
diff::git(&["worktree", "add", "--detach", &dir.to_string_lossy()])?;
}
std::env::set_current_dir(&dir)?;
progress(Some(&format!("checking out PR #{n} in {}", dir.display())));
gh::checkout(n, force, !branch).map_err(|e| {
let msg = format!("{e:#}");
let in_use = ["already checked out", "already used by worktree", "checked out at"].iter().any(|p| msg.contains(p));
let hint = match in_use {
true => "\nthat branch is checked out in another worktree: drop -b, or switch that checkout to another branch",
false => "",
};
e.context(format!("checking out PR #{n} in {}{hint}", dir.display()))
})?;
Ok(dir)
}
fn worktree_dir(n: u64) -> anyhow::Result<(PathBuf, PathBuf)> {
let common = PathBuf::from(common_dir(Path::new("."))?);
let main = match common.file_name().is_some_and(|f| f == ".git") {
true => common.parent().context("no repo dir")?.to_owned(),
false => PathBuf::from(diff::git(&["rev-parse", "--show-toplevel"])?.trim()),
};
let name = main.file_name().context("repo has no dir name")?.to_string_lossy().into_owned();
let dir = main.parent().context("repo has no parent dir")?.join(format!("{name}-{n}"));
Ok((main, dir))
}
pub(crate) fn pr_worktrees() -> Vec<(u64, PathBuf, Option<String>)> {
let Ok((main, _)) = worktree_dir(0) else { return vec![] };
let (Some(parent), Some(name)) = (main.parent(), main.file_name().map(|n| n.to_string_lossy().into_owned())) else { return vec![] };
let list = diff::git(&["worktree", "list", "--porcelain"]).unwrap_or_default();
let mut out = vec![];
for block in list.split("\n\n") {
let field = |k: &str| block.lines().find_map(|l| l.strip_prefix(k));
let Some(path) = field("worktree ").map(PathBuf::from) else { continue };
let n = path.file_name().and_then(|f| f.to_str()?.strip_prefix(&format!("{name}-"))?.parse().ok());
if let (Some(n), true) = (n, path.parent() == Some(parent)) {
out.push((n, path, field("branch refs/heads/").map(str::to_owned)));
}
}
out
}
fn common_dir(d: &Path) -> anyhow::Result<String> {
Ok(diff::git(&["-C", &d.to_string_lossy(), "rev-parse", "--path-format=absolute", "--git-common-dir"])?.trim().to_owned())
}
fn ensure_ours(dir: &Path) -> anyhow::Result<()> {
if common_dir(dir).ok() != Some(common_dir(Path::new("."))?) {
anyhow::bail!("{} exists and isn't a worktree of this repo", dir.display());
}
Ok(())
}
fn remove_worktree(n: u64, force: bool) -> anyhow::Result<()> {
let (main, dir) = worktree_dir(n)?;
if !dir.exists() {
anyhow::bail!("no worktree at {}", dir.display());
}
ensure_ours(&dir)?;
let branch = diff::git(&["-C", &dir.to_string_lossy(), "branch", "--show-current"]).unwrap_or_default();
std::env::set_current_dir(&main)?; let d = dir.to_string_lossy();
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(&d);
diff::git(&args).map_err(|e| match format!("{e:#}").contains("modified or untracked") {
true => e.context(format!("{} has local changes; `dv {n} --rm -f` discards them", dir.display())),
false => e,
})?;
println!("removed {}", dir.display());
if let Some(b) = Some(branch.trim()).filter(|b| !b.is_empty()) {
println!("kept branch {b} (delete it with `git branch -D {b}` if it has nothing unpushed)");
}
Ok(())
}
#[test]
fn args() {
let p = |s: &str| parse_args(s.split_whitespace().map(str::to_owned));
let pr = |n, w, b, f| Opts { pr: Some(n), worktree: w, branch: b, force: f, ..Opts::default() };
assert_eq!(p("2258 -wb").unwrap(), pr(2258, true, true, false));
assert_eq!(p("-wbf 2258").unwrap(), pr(2258, true, true, true));
assert_eq!(p("--pr 2258 -w -b").unwrap(), pr(2258, true, true, false));
assert_eq!(p("--pr=2258 --worktree").unwrap(), pr(2258, true, false, false));
assert_eq!(p("-p 7").unwrap(), pr(7, false, false, false));
assert_eq!(p("main").unwrap(), Opts { base: Some("main".into()), ..Opts::default() });
assert!(p("-wx").is_err());
assert_eq!(p("2258 --rm -f").unwrap(), Opts { pr: Some(2258), rm: true, force: true, ..Opts::default() });
let l = p("list -wbf").unwrap();
assert!(l.list && l.pr.is_none());
assert_eq!(l.flags(), "-wbf");
assert_eq!(p("list").unwrap().flags(), "");
assert_eq!(p("review-info -p 7").unwrap(), Opts { pr: Some(7), cmd: Some("review-info"), ..Opts::default() });
assert_eq!(p("review-check").unwrap().cmd, Some("review-check"));
}