mod batch;
mod claude;
mod diff;
mod gh;
mod highlight;
mod list;
mod lsp;
mod md;
mod merge;
mod replace;
mod theme;
mod todo;
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 review-comments for Claude: the PR's review comments on GitHub, as JSON (ids, lines,
who, what), to answer them in its review (read only)
dv rerun-failed for Claude fixing CI: rerun the failed jobs on the PR's head (only those)
dv replace 123 [124 …] Claude drafts a replacement for those PRs, unpublished, on
branch dv/replace-123 in a worktree <repo>-replace-123 (dv list's
replacements tab shows it)
dv replace-info for Claude: the replacement checked out here (its record, what it replaces)
dv install-skill install the dv-review skill for Claude Code (~/.claude/skills/dv-review)
dv version print dv's version (also --version, -V)
-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
--no-mouse leave the mouse to the terminal (dv: wheel scrolls, click moves, drag selects)
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,
no_mouse: bool,
list: bool,
cmd: Option<&'static str>,
more: Vec<u64>,
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,
"version" | "--version" | "-V" => o.cmd = Some("version"),
"-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,
"--no-mouse" => o.no_mouse = true,
"list" => o.list = true,
"review-info" => o.cmd = Some("review-info"),
"review-check" => o.cmd = Some("review-check"),
"review-comments" => o.cmd = Some("review-comments"),
"rerun-failed" => o.cmd = Some("rerun-failed"),
"install-skill" => o.cmd = Some("install-skill"),
"replace" => o.cmd = Some("replace"),
"replace-info" => o.cmd = Some("replace-info"),
"prepare-worktree" => o.cmd = Some("prepare-worktree"),
_ 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.is_some() && o.cmd == Some("replace") => {
o.more.push(num(Some(a))?)
}
_ 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))?;
view::MOUSE.store(!o.no_mouse, std::sync::atomic::Ordering::Relaxed);
if o.help {
println!("{USAGE}");
return Ok(());
}
if o.cmd == Some("version") {
println!("dv {}", env!("CARGO_PKG_VERSION"));
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).map(|m| println!("{m}"));
}
let root = PathBuf::from(diff::git(&["rev-parse", "--show-toplevel"])?.trim());
std::env::set_current_dir(&root)?;
if o.cmd != Some("install-skill") && claude::update_skill() {
progress(Some("updated Claude's dv-review skill to this dv's"));
}
match o.cmd {
Some("install-skill") => return install_skill(),
Some("prepare-worktree") => {
let n = o.pr.context("prepare-worktree needs a PR number")?;
let known = gh::pr(Some(n)).ok();
let (dir, _) = enter_worktree(n, o.branch, false, known.as_ref())?; println!("{}", dir.display());
return Ok(());
}
Some("replace-info") => return replace::info(),
Some("replace") => {
let prs: Vec<u64> = o.pr.into_iter().chain(o.more.iter().copied()).collect();
anyhow::ensure!(!prs.is_empty(), "dv replace needs the PRs to replace, e.g. `dv replace 123`");
let extra = if replace::is_new(prs[0]) { String::new() } else { claude::instructions("replace") };
eprintln!("Claude is drafting {} (dv list's drafts tab shows it)…", replace::label(prs[0]));
let took = replace::draft(&prs, &root, &extra, || {})?;
println!("drafted ({took}): `dv list`, drafts tab, or {}", worktree_dir_named(&root, &replace::name(prs[0]))?.display());
return Ok(());
}
Some(cmd) => return review_file(cmd, &o),
None => {}
}
diff::ensure_remote()?;
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, None)?;
if res.as_ref().is_ok_and(|m| *m) {
std::env::set_current_dir(&root)?; let n = o.pr.context("merging needs the PR's number")?;
eprintln!("{}", merge::merge(n, false)?);
return Ok(());
}
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.map(drop)
}
fn pick_and_review(o: &Opts, root: &Path) -> anyhow::Result<()> {
let mut list = list::List::new(o.flags());
loop {
let mut term = view::init_terminal();
let picked = list.run(&mut term);
view::restore_terminal();
let Some(pick) = picked? else { return Ok(()) };
let n = pick.number;
if pick.merge {
list.msg = merge::merge(n, view::in_zellij()).unwrap_or_else(|e| format!("not merged: {e:#}"));
std::env::set_current_dir(root)?;
continue;
}
if pick.replacement {
let res = review_replacement(n, root);
progress(None);
std::env::set_current_dir(root)?;
if let Err(e) = res {
list.msg = format!("{}: {e:#}", replace::label(n));
}
continue;
}
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), pick.pr).and_then(|(_, res)| res);
progress(None);
std::env::set_current_dir(root)?; match res {
Err(e) => list.msg = format!("PR #{n}: {e:#}"),
Ok(true) => list.msg = merge::merge(n, view::in_zellij()).unwrap_or_else(|e| format!("not merged: {e:#}")),
Ok(false) => {}
}
}
}
fn review_replacement(id: u64, root: &Path) -> anyhow::Result<()> {
let r = replace::load(id)?;
let dir = worktree_dir_named(root, &replace::name(id))?;
anyhow::ensure!(dir.exists(), "its worktree {} is gone (A A on the PR drafts it again)", dir.display());
ensure_worktree_of(&dir, root)?;
std::env::set_current_dir(&dir)?;
let base = diff::merge_base(Some(&crate::diff::remote_branch(&r.base)))?;
let mut app = view::App::new(base, None)?;
progress(None);
let mut term = view::init_terminal();
let res = app.run(&mut term);
view::restore_terminal();
res
}
fn review(o: &Opts, pr_num: Option<u64>, known: Option<gh::Pr>) -> anyhow::Result<(Option<PathBuf>, anyhow::Result<bool>)> {
let found = pr_num.filter(|_| !o.worktree).and_then(|n| pr_worktrees().into_iter().find(|w| w.0 == n));
let adjusted;
let o = match found {
Some((_, dir, branch)) => {
progress(Some(&format!("PR has a worktree at {}; opening it there", dir.display())));
adjusted = Opts { worktree: true, branch: branch.is_some(), base: o.base.clone(), force: o.force, ..Opts::default() };
&adjusted
}
None => o,
};
progress(Some("looking up the PR"));
let known = known.filter(|p| Some(p.number) == pr_num).or_else(|| pr_num.and_then(|n| gh::pr(Some(n)).ok()));
let (wt, note) = match (pr_num, o.worktree) {
(Some(n), true) => {
let (dir, note) = enter_worktree(n, o.branch, o.force, known.as_ref())?;
(Some(dir), note)
}
(Some(_), false) if known.as_ref().is_some_and(|p| at_head(p, false)) => (None, None),
(Some(n), false) => {
let on_it = known.as_ref().is_some_and(|p| {
let here = diff::git(&["branch", "--show-current"]).unwrap_or_default();
let here = here.trim();
here == p.head_branch || here == format!("{}-{}", p.head_owner, p.head_branch)
});
match on_it.then(|| local_work(n)).flatten() {
Some(note) => (None, Some(note)),
None => {
progress(Some(&format!("checking out PR #{n}")));
let note = checkout_or_keep(n, o.force, false)?;
record_checkout(n);
(None, note)
}
}
}
(None, _) => (None, None),
};
let pr = known.or_else(|| gh::pr(pr_num).ok());
let base = diff_base(o, pr.as_ref())?;
let follow = pr.as_ref().filter(|_| o.base.is_none()).map(|p| crate::diff::remote_branch(&p.base));
let app = view::App::new(base, pr);
progress(None);
let mut app = app?;
app.follow_base = follow;
if let Some(n) = note {
app.notice(n);
}
let mut term = view::init_terminal();
let res = app.run(&mut term);
view::restore_terminal();
Ok((wt, res.map(|_| app.merge)))
}
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) {
let here = !p.base_oid.is_empty() && diff::git(&["cat-file", "-e", &format!("{}^{{commit}}", p.base_oid)]).is_ok();
base = Some(match here {
true => p.base_oid.clone(),
false => {
progress(Some(&format!("fetching {}/{}", crate::diff::remote(), p.base)));
_ = diff::git(&["fetch", "-q", &crate::diff::remote(), &p.base]);
crate::diff::remote_branch(&p.base)
}
});
}
diff::merge_base(base.as_deref())
}
fn review_file(cmd: &str, o: &Opts) -> anyhow::Result<()> {
let pr = gh::pr(o.pr).ok();
if cmd == "review-comments" {
return review_comments(pr.as_ref());
}
if cmd == "rerun-failed" {
let p = pr.context("no PR here (dv rerun-failed -p N for PR N)")?;
println!("{}", gh::rerun_failed(&p.head_oid)?);
return 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());
let notes = claude::notes_path()?;
println!("notes: {} (your notes on this repo, kept across reviews; exists: {})", notes.display(), notes.exists());
if pr.is_some() {
println!("review comments: `dv review-comments` lists the PR's, on GitHub (to answer them)");
}
return Ok(());
}
let files = diff::load(&base, None, 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(())
}
fn review_comments(pr: Option<&gh::Pr>) -> anyhow::Result<()> {
let p = pr.context("no PR here (dv review-comments -p N for PR N)")?;
let review = gh::parse_threads(&gh::threads_json(p.number)?);
let threads: Vec<serde_json::Value> = review
.threads
.iter()
.map(|t| {
serde_json::json!({
"path": t.path, "line": t.line, "start_line": t.start_line, "side": if t.left { "old" } else { "new" },
"resolved": t.resolved, "outdated": t.outdated,
"comments": t.comments.iter().map(|c| serde_json::json!({ "id": c.id, "author": c.author, "body": c.body })).collect::<Vec<_>>(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&threads)?);
Ok(())
}
fn install_skill() -> anyhow::Result<()> {
let p = claude::skill_path()?;
std::fs::create_dir_all(p.parent().context("skill path")?)?;
std::fs::write(&p, claude::skill_file())?;
println!("installed {} (dv keeps it up to date from now on)", p.display());
Ok(())
}
pub(crate) fn checkout_or_keep(n: u64, force: bool, detach: bool) -> anyhow::Result<Option<String>> {
let Err(e) = gh::checkout(n, force, detach) else { return Ok(None) };
let msg = format!("{e:#}");
let diverged = ["Not possible to fast-forward", "Diverging branches"].iter().any(|p| msg.contains(p));
let here = diff::git(&["branch", "--show-current"]).unwrap_or_default().trim().to_owned();
let (theirs, owner) = gh::pr(Some(n)).map(|p| (p.head_branch, p.head_owner)).unwrap_or_default();
let on_it = !theirs.is_empty() && (here == theirs || here == format!("{owner}-{theirs}"));
match (diverged && !force && !detach, on_it) {
(true, true) => Ok(Some(format!(
"{here} has commits that aren't on PR #{n}: showing it as is (push to update the PR; -f resets to the PR)"
))),
(true, false) => Err(e.context(format!(
"your local branch for PR #{n} has diverged from the PR (local commits, or the PR was force-pushed); \
check it out to review it as is, or `dv {n} -f` to reset it to the PR (dropping its local commits)"
))),
_ => Err(e),
}
}
fn local_work(n: u64) -> Option<String> {
let head = diff::git(&["rev-parse", "HEAD"]).ok()?.trim().to_owned();
diff::git(&["fetch", "-q", &crate::diff::remote(), &format!("refs/pull/{n}/head")]).ok()?;
let pr = diff::git(&["rev-parse", "FETCH_HEAD"]).ok()?.trim().to_owned();
let behind = head == pr || diff::git(&["merge-base", "--is-ancestor", &head, &pr]).is_ok();
if behind || last_checkout(n).as_deref() == Some(head.as_str()) {
return None;
}
let ahead = diff::git(&["rev-list", "--count", &format!("{pr}..HEAD")]).unwrap_or_default();
let ahead = ahead.trim();
let (s, are, them) = if ahead == "1" { ("", "isn't", "it") } else { ("s", "aren't", "them") };
Some(format!(
"{ahead} commit{s} here {are} on PR #{n} (yours or Claude's): kept, not updated to the PR. \
Space g pushes {them}; to drop {them} instead, `git reset --hard {}`",
&pr[..pr.len().min(12)]
))
}
fn checkout_record(n: u64) -> Option<PathBuf> {
claude::dv_dir().ok().map(|d| d.join("checkouts").join(format!("pr-{n}")))
}
pub(crate) fn self_exe() -> PathBuf {
std::env::current_exe().map_or_else(|_| PathBuf::from("dv"), runnable)
}
fn runnable(p: PathBuf) -> PathBuf {
if p.exists() {
return p;
}
p.to_str().and_then(|s| s.strip_suffix(" (deleted)")).map(PathBuf::from).filter(|p| p.exists()).unwrap_or_else(|| PathBuf::from("dv"))
}
pub(crate) fn last_checkout(n: u64) -> Option<String> {
Some(std::fs::read_to_string(checkout_record(n)?).ok()?.trim().to_owned())
}
fn record_checkout(n: u64) {
let (Some(p), Ok(head)) = (checkout_record(n), diff::git(&["rev-parse", "HEAD"])) else { return };
_ = std::fs::create_dir_all(p.parent().unwrap_or(&p));
_ = std::fs::write(p, head.trim());
}
fn at_head(p: &gh::Pr, detach: bool) -> bool {
let head = diff::git(&["rev-parse", "HEAD"]).unwrap_or_default();
let here = diff::git(&["branch", "--show-current"]).unwrap_or_default();
let here = here.trim();
let on_it = match detach {
true => here.is_empty(),
false => !here.is_empty() && (here == p.head_branch || here == format!("{}-{}", p.head_owner, p.head_branch)),
};
!p.head_oid.is_empty() && head.trim() == p.head_oid && on_it
}
fn enter_worktree(n: u64, branch: bool, force: bool, known: Option<&gh::Pr>) -> anyhow::Result<(PathBuf, Option<String>)> {
let (_, dir) = worktree_dir(n)?;
if let Some(p) = known.filter(|_| branch)
&& let Some(other) = worktree_on_branch(&p.head_branch, &p.head_oid).filter(|d| *d != dir)
{
std::env::set_current_dir(&other)?;
let note = format!("#{n}'s branch {} is checked out in {}: opened there", p.head_branch, other.display());
return Ok((other, Some(note)));
}
let existed = dir.exists();
if existed {
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)?;
if known.is_some_and(|p| at_head(p, !branch)) {
record_checkout(n);
return Ok((dir, None));
}
if existed && let Some(note) = local_work(n) {
return Ok((dir, Some(note)));
}
progress(Some(&format!("checking out PR #{n} in {}", dir.display())));
let note = checkout_or_keep(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()))
})?;
record_checkout(n);
Ok((dir, note))
}
pub(crate) fn worktree_dir(n: u64) -> anyhow::Result<(PathBuf, PathBuf)> {
let main = main_checkout(Path::new("."))?;
Ok((main, worktree_dir_named(Path::new("."), &n.to_string())?))
}
fn main_checkout(d: &Path) -> anyhow::Result<PathBuf> {
let common = PathBuf::from(common_dir(d)?);
Ok(match common.file_name().is_some_and(|f| f == ".git") {
true => common.parent().context("no repo dir")?.to_owned(),
false => PathBuf::from(diff::git(&["-C", &d.to_string_lossy(), "rev-parse", "--show-toplevel"])?.trim()),
})
}
pub(crate) fn worktree_dir_named(d: &Path, suffix: &str) -> anyhow::Result<PathBuf> {
let main = main_checkout(d)?;
let name = main.file_name().context("repo has no dir name")?.to_string_lossy().into_owned();
Ok(main.parent().context("repo has no parent dir")?.join(format!("{name}-{suffix}")))
}
pub(crate) fn ensure_worktree_of(dir: &Path, root: &Path) -> anyhow::Result<()> {
if common_dir(dir).ok() != Some(common_dir(root)?) {
anyhow::bail!("{} exists and isn't a worktree of this repo", dir.display());
}
Ok(())
}
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 worktree_on_branch(branch: &str, oid: &str) -> Option<PathBuf> {
let list = diff::git(&["worktree", "list", "--porcelain"]).ok()?;
let dir = checked_out_at(&list, branch)?;
let d = dir.to_string_lossy().into_owned();
let git = |args: &[&str]| diff::git(&[&["-C", d.as_str()], args].concat()).is_ok();
let ours = !oid.is_empty() && (git(&["merge-base", "--is-ancestor", oid, "HEAD"]) || git(&["merge-base", "--is-ancestor", "HEAD", oid]));
ours.then_some(dir)
}
fn checked_out_at(list: &str, branch: &str) -> Option<PathBuf> {
let want = format!("branch refs/heads/{branch}");
list.split("\n\n").find(|w| w.lines().any(|l| l == want)).and_then(|w| w.lines().find_map(|l| l.strip_prefix("worktree "))).map(PathBuf::from)
}
fn ensure_ours(dir: &Path) -> anyhow::Result<()> {
ensure_worktree_of(dir, Path::new("."))
}
pub(crate) fn remove_worktree(n: u64, force: bool) -> anyhow::Result<String> {
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,
})?;
Ok(match Some(branch.trim()).filter(|b| !b.is_empty()) {
Some(b) => format!("removed {}; kept branch {b} (`git branch -D {b}` deletes it, if nothing on it is unpushed)", dir.display()),
None => format!("removed {}", dir.display()),
})
}
#[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() });
for v in ["version", "--version", "-V"] {
assert_eq!(p(v).unwrap(), Opts { cmd: Some("version"), ..Opts::default() });
}
assert_eq!(p("review-check").unwrap().cmd, Some("review-check"));
let r = p("replace 12 15").unwrap();
assert_eq!((r.cmd, r.pr, r.more), (Some("replace"), Some(12), vec![15]));
}
#[test]
fn branch_checked_out_elsewhere() {
let list = "worktree /r/bdk\nHEAD 1111\nbranch refs/heads/master\n\nworktree /r/bdk-2390\nHEAD 2222\ndetached\n\nworktree /r/bdk-replace-2345\nHEAD 8e81\nbranch refs/heads/dv/replace-2345\n";
assert_eq!(checked_out_at(list, "dv/replace-2345"), Some(PathBuf::from("/r/bdk-replace-2345")));
assert_eq!(checked_out_at(list, "dv/replace"), None); assert_eq!(checked_out_at(list, "fix"), None);
}
#[test]
fn self_exe_after_an_install() {
assert!(self_exe().exists());
let dir = std::env::temp_dir().join(format!("dv-self-exe-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("dv");
std::fs::write(&bin, "").unwrap();
assert_eq!(runnable(dir.join("dv (deleted)")), bin);
assert_eq!(runnable(dir.join("gone (deleted)")), PathBuf::from("dv")); _ = std::fs::remove_dir_all(&dir);
}