Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: move rad review to rad patch
Fintan Halpenny committed 2 years ago
commit ee9ee691306a8accc627a00835a3116e9d235814
parent 6a3a66ae9c5d916af014413e79a0df7279c8ef1c
10 files changed +642 -706
modified radicle-cli/examples/rad-patch.md
@@ -154,7 +154,7 @@ $ rad patch checkout 6ff4f09
We can also add a review verdict as such:

```
-
$ rad review 6ff4f09 --accept --no-message --no-sync
+
$ rad patch review 6ff4f09 --accept --no-message
✓ Patch 6ff4f09 accepted
```

modified radicle-cli/examples/rad-review-by-hunk.md
@@ -70,7 +70,7 @@ Finally, we do a review of the patch by hunk. The output of this command should
match `git diff master -W100% -U5 --patience`:

```
-
$ rad review --no-sync --patch -U5 52da8356aa9beec08e6943cb3c4063fa37f3241b
+
$ rad patch review --patch -U5 52da8356aa9beec08e6943cb3c4063fa37f3241b
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 7937fb3..0000000
@@ -116,7 +116,7 @@ rename to notes/INSTRUCTIONS.txt
Now let's accept these hunks one by one..

```
-
$ rad review --no-sync --patch --accept --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
+
$ rad patch review --patch --accept --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
✓ Loaded existing review ([..]) for patch 52da8356aa9beec08e6943cb3c4063fa37f3241b
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
@@ -127,7 +127,7 @@ index 7937fb3..0000000
-*.draft
```
```
-
$ rad review --no-sync --patch --accept --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
+
$ rad patch review --patch --accept --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
✓ Loaded existing review ([..]) for patch 52da8356aa9beec08e6943cb3c4063fa37f3241b
diff --git a/DISCLAIMER.txt b/DISCLAIMER.txt
new file mode 100644
@@ -138,7 +138,7 @@ index 0000000..2b5bd86
+All food is served as-is, with no warranty!
```
```
-
$ rad review --no-sync --patch --accept -U3 --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
+
$ rad patch review --patch --accept -U3 --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
✓ Loaded existing review ([..]) for patch 52da8356aa9beec08e6943cb3c4063fa37f3241b
diff --git a/MENU.txt b/MENU.txt
index 867958c..3af9741 100644
@@ -153,7 +153,7 @@ index 867958c..3af9741 100644
[..]
```
```
-
$ rad review --no-sync --patch --accept -U3 --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
+
$ rad patch review --patch --accept -U3 --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
✓ Loaded existing review ([..]) for patch 52da8356aa9beec08e6943cb3c4063fa37f3241b
diff --git a/MENU.txt b/MENU.txt
index 4e2e828..3af9741 100644
@@ -169,7 +169,7 @@ index 4e2e828..3af9741 100644
```

```
-
$ rad review --no-sync --patch --accept --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
+
$ rad patch review --patch --accept --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
✓ Loaded existing review ([..]) for patch 52da8356aa9beec08e6943cb3c4063fa37f3241b
diff --git a/INSTRUCTIONS.txt b/notes/INSTRUCTIONS.txt
similarity index 100%
@@ -178,7 +178,7 @@ rename to notes/INSTRUCTIONS.txt
```

```
-
$ rad review --no-sync --patch --accept --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
+
$ rad patch review --patch --accept --hunk 1 52da8356aa9beec08e6943cb3c4063fa37f3241b
✓ Loaded existing review ([..]) for patch 52da8356aa9beec08e6943cb3c4063fa37f3241b
✓ All hunks have been reviewed
```
modified radicle-cli/src/commands.rs
@@ -36,8 +36,6 @@ pub mod rad_path;
pub mod rad_publish;
#[path = "commands/remote.rs"]
pub mod rad_remote;
-
#[path = "commands/review.rs"]
-
pub mod rad_review;
#[path = "commands/self.rs"]
pub mod rad_self;
#[path = "commands/sync.rs"]
modified radicle-cli/src/commands/help.rs
@@ -26,7 +26,6 @@ const COMMANDS: &[Help] = &[
    rad_node::HELP,
    rad_patch::HELP,
    rad_path::HELP,
-
    rad_review::HELP,
    rad_clean::HELP,
    rad_self::HELP,
    rad_track::HELP,
modified radicle-cli/src/commands/patch.rs
@@ -20,6 +20,8 @@ mod list;
mod ready;
#[path = "patch/redact.rs"]
mod redact;
+
#[path = "patch/review.rs"]
+
mod review;
#[path = "patch/show.rs"]
mod show;
#[path = "patch/update.rs"]
@@ -53,6 +55,7 @@ Usage
    rad patch archive <patch-id> [<option>...]
    rad patch update <patch-id> [<option>...]
    rad patch checkout <patch-id> [<option>...]
+
    rad patch review <patch-id> [--accept | --reject] [-m [<string>]] [-d | --delete] [<option>...]
    rad patch delete <patch-id> [<option>...]
    rad patch redact <revision-id> [<option>...]
    rad patch assign <revision-id> [--add <did>] [--delete <did>] [<option>...]
@@ -75,6 +78,18 @@ Edit options

    -m, --message [<string>]   Provide a comment message to the patch or revision (default: prompt)

+
Review options
+

+
        --revision <id>        Review the given revision of the patch
+
    -p, --patch                Review by patch hunks
+
        --hunk <index>         Only review a specific hunk
+
        --accept               Accept a patch or set of hunks
+
        --reject               Reject a patch or set of hunks
+
    -U, --unified <n>          Generate diffs with <n> lines of context instead of the usual three
+
    -d, --delete               Delete a review draft
+
    -r, --revision             Review a patch revision
+
    -m, --message [<string>]   Provide a comment with the review (default: prompt)
+

Assign options

    -a, --add    <did>         Add an assignee to the patch (may be specified multiple times).
@@ -134,6 +149,7 @@ pub enum OperationName {
    Checkout,
    Comment,
    Ready,
+
    Review,
    Label,
    #[default]
    List,
@@ -205,6 +221,11 @@ pub enum Operation {
        message: Message,
        reply_to: Option<Rev>,
    },
+
    Review {
+
        patch_id: Rev,
+
        revision_id: Option<Rev>,
+
        opts: review::Options,
+
    },
    Assign {
        patch_id: Rev,
        opts: AssignOptions,
@@ -261,6 +282,7 @@ impl Args for Options {
        let mut checkout_opts = checkout::Options::default();
        let mut assign_opts = AssignOptions::default();
        let mut label_opts = LabelOptions::default();
+
        let mut review_op = review::Operation::default();

        while let Some(arg) = parser.next()? {
            match arg {
@@ -314,6 +336,65 @@ impl Args for Options {
                    reply_to = Some(rev);
                }

+
                // Review options.
+
                Long("revision") if op == Some(OperationName::Review) => {
+
                    let val = parser.value()?;
+
                    let rev = term::args::oid(&val)?;
+

+
                    revision_id = Some(rev);
+
                }
+
                Long("patch") | Short('p') if op == Some(OperationName::Review) => {
+
                    if let review::Operation::Review { by_hunk, .. } = &mut review_op {
+
                        *by_hunk = true;
+
                    } else {
+
                        return Err(arg.unexpected().into());
+
                    }
+
                }
+
                Long("unified") | Short('U') if op == Some(OperationName::Review) => {
+
                    if let review::Operation::Review { unified, .. } = &mut review_op {
+
                        let val = parser.value()?;
+
                        *unified = term::args::number(&val)?;
+
                    } else {
+
                        return Err(arg.unexpected().into());
+
                    }
+
                }
+
                Long("hunk") if op == Some(OperationName::Review) => {
+
                    if let review::Operation::Review { hunk, .. } = &mut review_op {
+
                        let val = parser.value()?;
+
                        let val = term::args::number(&val)
+
                            .map_err(|e| anyhow!("invalid hunk value: {e}"))?;
+

+
                        *hunk = Some(val);
+
                    } else {
+
                        return Err(arg.unexpected().into());
+
                    }
+
                }
+
                Long("delete") | Short('d') if op == Some(OperationName::Review) => {
+
                    review_op = review::Operation::Delete;
+
                }
+
                Long("accept") if op == Some(OperationName::Review) => {
+
                    if let review::Operation::Review {
+
                        verdict: verdict @ None,
+
                        ..
+
                    } = &mut review_op
+
                    {
+
                        *verdict = Some(patch::Verdict::Accept);
+
                    } else {
+
                        return Err(arg.unexpected().into());
+
                    }
+
                }
+
                Long("reject") if op == Some(OperationName::Review) => {
+
                    if let review::Operation::Review {
+
                        verdict: verdict @ None,
+
                        ..
+
                    } = &mut review_op
+
                    {
+
                        *verdict = Some(patch::Verdict::Reject);
+
                    } else {
+
                        return Err(arg.unexpected().into());
+
                    }
+
                }
+

                // Checkout options
                Long("revision") if op == Some(OperationName::Checkout) => {
                    let val = parser.value()?;
@@ -409,6 +490,7 @@ impl Args for Options {
                    "assign" => op = Some(OperationName::Assign),
                    "label" => op = Some(OperationName::Label),
                    "comment" => op = Some(OperationName::Comment),
+
                    "review" => op = Some(OperationName::Review),
                    "set" => op = Some(OperationName::Set),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
@@ -426,6 +508,7 @@ impl Args for Options {
                            Some(OperationName::Ready),
                            Some(OperationName::Checkout),
                            Some(OperationName::Comment),
+
                            Some(OperationName::Review),
                            Some(OperationName::Edit),
                            Some(OperationName::Set),
                            Some(OperationName::Assign),
@@ -467,6 +550,15 @@ impl Args for Options {
                message,
                reply_to,
            },
+
            OperationName::Review => Operation::Review {
+
                patch_id: patch_id
+
                    .ok_or_else(|| anyhow!("a patch or revision must be provided"))?,
+
                revision_id,
+
                opts: review::Options {
+
                    message,
+
                    op: review_op,
+
                },
+
            },
            OperationName::Ready => Operation::Ready {
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
                undo,
@@ -585,6 +677,18 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                &profile,
            )?;
        }
+
        Operation::Review {
+
            patch_id,
+
            revision_id,
+
            opts,
+
        } => {
+
            let patch_id = patch_id.resolve(&repository.backend)?;
+
            let revision_id = revision_id
+
                .map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
+
                .transpose()?
+
                .map(patch::RevisionId::from);
+
            review::run(patch_id, revision_id, opts, &profile, &repository)?;
+
        }
        Operation::Edit { patch_id, message } => {
            let patch_id = patch_id.resolve(&repository.backend)?;
            edit::run(&patch_id, message, &profile, &repository)?;
added radicle-cli/src/commands/patch/review.rs
@@ -0,0 +1,141 @@
+
#[path = "review/builder.rs"]
+
mod builder;
+

+
use anyhow::{anyhow, Context};
+

+
use radicle::cob::patch::{PatchId, Patches, RevisionId, Verdict};
+
use radicle::git;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+

+
use crate::terminal as term;
+
use crate::terminal::patch::Message;
+

+
/// Review help message.
+
pub const REVIEW_HELP_MSG: &str = r#"
+
<!--
+
You may enter a review comment here. If you leave this blank,
+
no comment will be attached to your review.
+

+
Markdown supported.
+
-->
+
"#;
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub enum Operation {
+
    Delete,
+
    Review {
+
        by_hunk: bool,
+
        unified: usize,
+
        hunk: Option<usize>,
+
        verdict: Option<Verdict>,
+
    },
+
}
+

+
impl Default for Operation {
+
    fn default() -> Self {
+
        Self::Review {
+
            by_hunk: false,
+
            unified: 3,
+
            hunk: None,
+
            verdict: None,
+
        }
+
    }
+
}
+

+
#[derive(Debug, Default)]
+
pub struct Options {
+
    pub message: Message,
+
    pub op: Operation,
+
}
+

+
pub fn run(
+
    patch_id: PatchId,
+
    revision_id: Option<RevisionId>,
+
    options: Options,
+
    profile: &Profile,
+
    repository: &Repository,
+
) -> anyhow::Result<()> {
+
    let signer = term::signer(profile)?;
+
    let _project = repository.identity_doc().context(format!(
+
        "couldn't load project {} from local state",
+
        repository.id
+
    ))?;
+
    let mut patches = Patches::open(repository)?;
+

+
    let mut patch = patches
+
        .get_mut(&patch_id)
+
        .context(format!("couldn't find patch {patch_id} locally"))?;
+

+
    let (revision_id, revision) = match revision_id {
+
        Some(id) => (
+
            id,
+
            patch
+
                .revision(&id)
+
                .ok_or_else(|| anyhow!("Patch revision `{id}` not found"))?,
+
        ),
+
        None => patch.latest(),
+
    };
+

+
    let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
+
    match options.op {
+
        Operation::Review {
+
            verdict,
+
            by_hunk,
+
            unified,
+
            hunk,
+
        } if by_hunk => {
+
            let mut opts = git::raw::DiffOptions::new();
+
            opts.patience(true)
+
                .minimal(true)
+
                .context_lines(unified as u32);
+

+
            builder::ReviewBuilder::new(patch_id, *profile.id(), repository)
+
                .hunk(hunk)
+
                .verdict(verdict)
+
                .run(revision, &mut opts)?;
+
        }
+
        Operation::Review { verdict, .. } => {
+
            let message = options.message.get(REVIEW_HELP_MSG)?;
+
            let message = message.replace(REVIEW_HELP_MSG.trim(), "");
+
            let message = if message.is_empty() {
+
                None
+
            } else {
+
                Some(message)
+
            };
+
            patch.review(revision_id, verdict, message, vec![], &signer)?;
+

+
            match verdict {
+
                Some(Verdict::Accept) => {
+
                    term::success!(
+
                        "Patch {} {}",
+
                        patch_id_pretty,
+
                        term::format::highlight("accepted")
+
                    );
+
                }
+
                Some(Verdict::Reject) => {
+
                    term::success!(
+
                        "Patch {} {}",
+
                        patch_id_pretty,
+
                        term::format::negative("rejected")
+
                    );
+
                }
+
                None => {
+
                    term::success!("Patch {} reviewed", patch_id_pretty);
+
                }
+
            }
+
        }
+
        Operation::Delete => {
+
            let name = git::refs::storage::draft::review(profile.id(), &patch_id);
+

+
            match repository.backend.find_reference(&name) {
+
                Ok(mut r) => r.delete()?,
+
                Err(e) => {
+
                    anyhow::bail!("Couldn't delete review reference '{name}': {e}");
+
                }
+
            }
+
        }
+
    }
+

+
    Ok(())
+
}
added radicle-cli/src/commands/patch/review/builder.rs
@@ -0,0 +1,389 @@
+
//! Review builder.
+
//!
+
//! This module enables a user to review a patch by interactively viewing and accepting diff hunks.
+
//! The interaction and output is modeled around `git add -p`.
+
//!
+
//! To implement this behavior, we keep a hidden Git tree object that tracks the state of the
+
//! repository including the accepted hunks. Thus, every time a diff hunk is accepted, it is applied
+
//! to that tree. We call that tree the "brain", as it tracks what the code reviewer has reviewed.
+
//!
+
//! The brain starts out equalling the tree of the base branch, and eventually, when the brain
+
//! matches the tree of the patch being reviewed (by accepting hunks), we can say that the patch has
+
//! been fully reviewed.
+
//!
+
use std::collections::VecDeque;
+
use std::io::IsTerminal as _;
+
use std::str::FromStr;
+
use std::{fmt, io};
+

+
use radicle::cob::patch::{PatchId, Revision, Verdict};
+
use radicle::git;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+
use radicle_surf::diff::*;
+

+
use crate::git::unified_diff;
+
use crate::terminal as term;
+

+
/// Help message shown to user.
+
const HELP: &str = "\
+
y - accept this hunk
+
n - ignore this hunk
+
c - comment on this hunk
+
j - leave this hunk undecided, see next hunk
+
k - leave this hunk undecided, see previous hunk
+
s - split the current hunk into smaller hunks
+
q - quit; do not accept this hunk nor any of the remaining ones
+
? - print help";
+

+
/// The actions that a user can carry out on a review item.
+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+
pub enum ReviewAction {
+
    Accept,
+
    Ignore,
+
    Comment,
+
    Split,
+
    Next,
+
    Previous,
+
    Help,
+
    Quit,
+
}
+

+
impl ReviewAction {
+
    /// Ask the user what action to take.
+
    fn prompt(
+
        mut input: impl io::BufRead,
+
        mut output: impl io::Write,
+
        prompt: impl fmt::Display,
+
    ) -> io::Result<Option<Self>> {
+
        write!(&mut output, "{prompt} ")?;
+

+
        let mut s = String::new();
+
        input.read_line(&mut s)?;
+

+
        if s.trim().is_empty() {
+
            return Ok(None);
+
        }
+
        Self::from_str(s.trim())
+
            .map(Some)
+
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
+
    }
+
}
+

+
impl std::fmt::Display for ReviewAction {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Self::Accept => write!(f, "y"),
+
            Self::Ignore => write!(f, "n"),
+
            Self::Comment => write!(f, "c"),
+
            Self::Split => write!(f, "s"),
+
            Self::Next => write!(f, "j"),
+
            Self::Previous => write!(f, "k"),
+
            Self::Help => write!(f, "?"),
+
            Self::Quit => write!(f, "q"),
+
        }
+
    }
+
}
+

+
impl FromStr for ReviewAction {
+
    type Err = io::Error;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        match s {
+
            "y" => Ok(Self::Accept),
+
            "n" => Ok(Self::Ignore),
+
            "c" => Ok(Self::Comment),
+
            "s" => Ok(Self::Split),
+
            "j" => Ok(Self::Next),
+
            "k" => Ok(Self::Previous),
+
            "?" => Ok(Self::Help),
+
            "q" => Ok(Self::Quit),
+
            _ => Err(io::Error::new(
+
                io::ErrorKind::InvalidInput,
+
                format!("invalid action '{s}'"),
+
            )),
+
        }
+
    }
+
}
+

+
/// A single review item. Can be a hunk or eg. a file move.
+
pub struct ReviewItem<'a> {
+
    file: &'a FileDiff,
+
    hunk: Option<&'a Hunk<Modification>>,
+
}
+

+
/// Queue of items (usually hunks) left to review.
+
#[derive(Default)]
+
pub struct ReviewQueue<'a> {
+
    queue: VecDeque<(usize, ReviewItem<'a>)>,
+
}
+

+
impl<'a> ReviewQueue<'a> {
+
    /// Push an item to the queue.
+
    fn push(&mut self, file: &'a FileDiff, hunks: Option<&'a Hunks<Modification>>) {
+
        let mut queue_item = |hunk| {
+
            self.queue
+
                .push_back((self.queue.len(), ReviewItem { file, hunk }))
+
        };
+

+
        if let Some(hunks) = hunks {
+
            for hunk in hunks.iter() {
+
                queue_item(Some(hunk));
+
            }
+
        } else {
+
            queue_item(None);
+
        }
+
    }
+
}
+

+
impl<'a> std::ops::Deref for ReviewQueue<'a> {
+
    type Target = VecDeque<(usize, ReviewItem<'a>)>;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.queue
+
    }
+
}
+

+
impl<'a> std::ops::DerefMut for ReviewQueue<'a> {
+
    fn deref_mut(&mut self) -> &mut Self::Target {
+
        &mut self.queue
+
    }
+
}
+

+
impl<'a> Iterator for ReviewQueue<'a> {
+
    type Item = (usize, ReviewItem<'a>);
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        self.queue.pop_front()
+
    }
+
}
+

+
/// Builds a patch review interactively.
+
pub struct ReviewBuilder<'a> {
+
    /// Patch being reviewed.
+
    patch_id: PatchId,
+
    /// Where the review draft is being stored.
+
    refname: git::Namespaced<'a>,
+
    /// Stored copy of repository.
+
    repo: &'a Repository,
+
    /// Single hunk review.
+
    hunk: Option<usize>,
+
    /// Verdict for review items.
+
    verdict: Option<Verdict>,
+
}
+

+
impl<'a> ReviewBuilder<'a> {
+
    /// Create a new review builder.
+
    pub fn new(patch_id: PatchId, nid: NodeId, repo: &'a Repository) -> Self {
+
        Self {
+
            patch_id,
+
            refname: git::refs::storage::draft::review(&nid, &patch_id),
+
            repo,
+
            hunk: None,
+
            verdict: None,
+
        }
+
    }
+

+
    /// Review a single hunk. Set to `None` to review all hunks.
+
    pub fn hunk(mut self, hunk: Option<usize>) -> Self {
+
        self.hunk = hunk;
+
        self
+
    }
+

+
    /// Give this verdict to all review items. Set to `None` to not give a verdict.
+
    pub fn verdict(mut self, verdict: Option<Verdict>) -> Self {
+
        self.verdict = verdict;
+
        self
+
    }
+

+
    /// Run the review builder for the given revision.
+
    pub fn run(self, revision: &Revision, opts: &mut git::raw::DiffOptions) -> anyhow::Result<()> {
+
        let repo = self.repo.raw();
+
        let base = repo.find_commit((*revision.base()).into())?;
+
        let author = repo.signature()?;
+
        let patch_id = self.patch_id;
+
        let review = if let Ok(c) = self.current() {
+
            term::success!(
+
                "Loaded existing review {} for patch {}",
+
                term::format::secondary(term::format::parens(term::format::oid(c.id()))),
+
                term::format::tertiary(&patch_id)
+
            );
+
            c
+
        } else {
+
            let oid = repo.commit(
+
                Some(self.refname.as_str()),
+
                &author,
+
                &author,
+
                &format!("Review {patch_id}"),
+
                &base.tree()?,
+
                &[&base],
+
            )?;
+
            repo.find_commit(oid)?
+
        };
+

+
        let mut writer = unified_diff::Writer::new(io::stdout()).styled(true);
+
        let mut queue = ReviewQueue::default(); // Queue of hunks to review.
+
        let mut current = None; // File of the current hunk.
+
        let mut stdin = io::stdin().lock();
+
        let mut stderr = io::stderr().lock();
+

+
        let commit = repo.find_commit(revision.head().into())?;
+
        let tree = commit.tree()?;
+
        let brain = review.tree()?;
+

+
        let mut find_opts = git::raw::DiffFindOptions::new();
+
        find_opts.exact_match_only(true);
+
        find_opts.all(true);
+
        find_opts.copies(false); // We don't support finding copies at the moment.
+

+
        let mut diff = repo.diff_tree_to_tree(Some(&brain), Some(&tree), Some(opts))?;
+
        diff.find_similar(Some(&mut find_opts))?;
+

+
        if diff.deltas().next().is_none() {
+
            term::success!("All hunks have been reviewed");
+
            return Ok(());
+
        }
+
        let diff = Diff::try_from(diff)?;
+

+
        for file in diff.files() {
+
            match file {
+
                FileDiff::Modified(f) => match &f.diff {
+
                    DiffContent::Plain { hunks, .. } => queue.push(file, Some(hunks)),
+
                    DiffContent::Binary => queue.push(file, None),
+
                    DiffContent::Empty => {}
+
                },
+
                FileDiff::Added(f) => match &f.diff {
+
                    DiffContent::Plain { hunks, .. } => queue.push(file, Some(hunks)),
+
                    DiffContent::Binary => queue.push(file, None),
+
                    DiffContent::Empty => {}
+
                },
+
                FileDiff::Deleted(f) => match &f.diff {
+
                    DiffContent::Plain { hunks, .. } => queue.push(file, Some(hunks)),
+
                    DiffContent::Binary => queue.push(file, None),
+
                    DiffContent::Empty => {}
+
                },
+
                FileDiff::Moved(_) => queue.push(file, None),
+
                FileDiff::Copied(_) => {
+
                    // Copies are not supported and should never be generated due to the diff
+
                    // options we pass.
+
                    panic!("ReviewBuilder::by_hunk: copy diffs are not supported");
+
                }
+
            }
+
        }
+
        let total = queue.len();
+

+
        while let Some((ix, item)) = queue.next() {
+
            if let Some(hunk) = self.hunk {
+
                if hunk != ix + 1 {
+
                    continue;
+
                }
+
            }
+
            let progress = term::format::secondary(format!("({}/{total})", ix + 1));
+
            let ReviewItem { file, hunk } = item;
+

+
            if current.map_or(true, |c| c != file) {
+
                writer.encode(&unified_diff::FileHeader::from(file))?;
+
                current = Some(file);
+
            }
+
            if let Some(h) = hunk {
+
                writer.encode(h)?;
+
            }
+

+
            match self.prompt(&mut stdin, &mut stderr, progress) {
+
                Some(ReviewAction::Accept) => {
+
                    let mut buf = Vec::new();
+
                    {
+
                        let mut writer = unified_diff::Writer::new(&mut buf);
+

+
                        writer.encode(&unified_diff::FileHeader::from(file))?;
+
                        if let Some(h) = hunk {
+
                            writer.encode(h)?;
+
                        }
+
                    }
+
                    let diff = git::raw::Diff::from_buffer(&buf)?;
+

+
                    let mut index = repo.apply_to_tree(&brain, &diff, None)?;
+
                    let brain = index.write_tree_to(repo)?;
+
                    let brain = repo.find_tree(brain)?;
+

+
                    let _oid =
+
                        review.amend(Some(&self.refname), None, None, None, None, Some(&brain))?;
+
                }
+
                Some(ReviewAction::Ignore) => {
+
                    // Do nothing. Hunk will be reviewable again next time.
+
                }
+
                Some(ReviewAction::Comment) => {
+
                    eprintln!(
+
                        "{}",
+
                        term::format::tertiary("Commenting is not yet implemented").bold()
+
                    );
+
                    queue.push_front((ix, item));
+
                }
+
                Some(ReviewAction::Split) => {
+
                    eprintln!(
+
                        "{}",
+
                        term::format::tertiary("Splitting is not yet implemented").bold()
+
                    );
+
                    queue.push_front((ix, item));
+
                }
+
                Some(ReviewAction::Next) => {
+
                    queue.push_back((ix, item));
+
                }
+
                Some(ReviewAction::Previous) => {
+
                    queue.push_front((ix, item));
+

+
                    if let Some(e) = queue.pop_back() {
+
                        queue.push_front(e);
+
                    }
+
                }
+
                Some(ReviewAction::Quit) => {
+
                    break;
+
                }
+
                Some(ReviewAction::Help) => {
+
                    eprintln!("{}", term::format::tertiary(HELP).bold());
+
                    queue.push_front((ix, item));
+
                }
+
                None => {
+
                    eprintln!(
+
                        "{}",
+
                        term::format::secondary(format!(
+
                            "{} hunk(s) remaining to review",
+
                            queue.len() + 1
+
                        ))
+
                    );
+
                    queue.push_front((ix, item));
+
                }
+
            }
+
        }
+

+
        Ok(())
+
    }
+

+
    fn prompt(
+
        &self,
+
        mut input: impl io::BufRead,
+
        mut output: &mut io::StderrLock,
+
        progress: impl fmt::Display,
+
    ) -> Option<ReviewAction> {
+
        if let Some(v) = self.verdict {
+
            match v {
+
                Verdict::Accept => Some(ReviewAction::Accept),
+
                Verdict::Reject => Some(ReviewAction::Ignore),
+
            }
+
        } else if output.is_terminal() {
+
            let prompt = term::format::secondary("Accept this hunk? [y,n,c,j,k,?]").bold();
+

+
            ReviewAction::prompt(&mut input, &mut output, format!("{progress} {prompt}"))
+
                .unwrap_or(Some(ReviewAction::Help))
+
        } else {
+
            Some(ReviewAction::Ignore)
+
        }
+
    }
+

+
    fn current(&self) -> Result<git::raw::Commit, git::raw::Error> {
+
        self.repo
+
            .raw()
+
            .find_reference(&self.refname)?
+
            .peel_to_commit()
+
    }
+
}
deleted radicle-cli/src/commands/review.rs
@@ -1,299 +0,0 @@
-
#[path = "review/builder.rs"]
-
mod builder;
-

-
use std::ffi::OsString;
-

-
use anyhow::{anyhow, Context};
-

-
use radicle::cob::patch::{PatchId, Patches, RevisionId, Verdict};
-
use radicle::prelude::*;
-
use radicle::{git, rad};
-

-
use crate::git::Rev;
-
use crate::terminal as term;
-
use crate::terminal::args::{string, Args, Error, Help};
-
use crate::terminal::patch::Message;
-

-
pub const HELP: Help = Help {
-
    name: "review",
-
    description: "Approve or reject a patch",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad review [<patch-id>] [--accept | --reject] [-m [<string>]] [<option>...]
-
    rad review [<patch-id>] [-d | --delete]
-

-
    To specify a patch to review, use the fully qualified patch id
-
    or an unambiguous prefix of it.
-

-
    In scripting contexts, patch mode can be used non-interactively,
-
    by passing eg. the `--hunk` and `--accept` options.
-

-
Options
-

-
    -p, --patch               Review by patch hunks
-
        --hunk <index>        Only review a specific hunk
-
        --accept              Accept a patch or set of hunks
-
        --reject              Reject a patch or set of hunks
-
    -U, --unified <n>         Generate diffs with <n> lines of context instead of the usual three
-
    -d, --delete              Delete a review draft
-
    -r, --revision            Review a patch revision
-
        --[no-]sync           Sync review to seed (default: sync)
-
    -m, --message [<string>]  Provide a comment with the review (default: prompt)
-
        --help                Print help
-
"#,
-
};
-

-
/// Review help message.
-
pub const REVIEW_HELP_MSG: &str = r#"
-
<!--
-
You may enter a review comment here. If you leave this blank,
-
no comment will be attached to your review.
-

-
Markdown supported.
-
-->
-
"#;
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
-
    Delete,
-
    Review {
-
        by_hunk: bool,
-
        unified: usize,
-
        hunk: Option<usize>,
-
        verdict: Option<Verdict>,
-
    },
-
}
-

-
impl Default for Operation {
-
    fn default() -> Self {
-
        Self::Review {
-
            by_hunk: false,
-
            unified: 3,
-
            hunk: None,
-
            verdict: None,
-
        }
-
    }
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub id: Rev,
-
    pub revision: bool,
-
    pub message: Message,
-
    pub sync: bool,
-
    pub verbose: bool,
-
    pub op: Operation,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut id: Option<Rev> = None;
-
        let mut revision = false;
-
        let mut message = Message::default();
-
        let mut sync = true;
-
        let mut verbose = false;
-
        let mut op = Operation::default();
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("revision") | Short('r') => {
-
                    revision = true;
-
                }
-
                Long("sync") => {
-
                    // Skipping due the `no-sync` flag precedence.
-
                }
-
                Long("no-sync") => {
-
                    sync = false;
-
                }
-
                Long("message") | Short('m') => {
-
                    if message != Message::Blank {
-
                        let txt: String = parser.value()?.to_string_lossy().into();
-
                        message.append(&txt);
-
                    }
-
                }
-
                Long("no-message") => {
-
                    message = Message::Blank;
-
                }
-
                Long("patch") | Short('p') => {
-
                    if let Operation::Review { by_hunk, .. } = &mut op {
-
                        *by_hunk = true;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("unified") | Short('U') => {
-
                    if let Operation::Review { unified, .. } = &mut op {
-
                        let val = parser.value()?;
-
                        *unified = term::args::number(&val)?;
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("hunk") => {
-
                    if let Operation::Review { hunk, .. } = &mut op {
-
                        let val = parser.value()?;
-
                        let val = term::args::number(&val)
-
                            .map_err(|e| anyhow!("invalid hunk value: {e}"))?;
-

-
                        *hunk = Some(val);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("delete") | Short('d') => {
-
                    op = Operation::Delete;
-
                }
-
                Long("verbose") | Short('v') => {
-
                    verbose = true;
-
                }
-
                Long("accept") => {
-
                    if let Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut op
-
                    {
-
                        *verdict = Some(Verdict::Accept);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Long("reject") => {
-
                    if let Operation::Review {
-
                        verdict: verdict @ None,
-
                        ..
-
                    } = &mut op
-
                    {
-
                        *verdict = Some(Verdict::Reject);
-
                    } else {
-
                        return Err(arg.unexpected().into());
-
                    }
-
                }
-
                Value(val) => {
-
                    id = Some(Rev::from(string(&val)));
-
                }
-
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        Ok((
-
            Options {
-
                id: id.ok_or_else(|| anyhow!("a patch to review must be provided"))?,
-
                message,
-
                sync,
-
                revision,
-
                verbose,
-
                op,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let (_, id) =
-
        rad::cwd().map_err(|_| anyhow!("this command must be run in the context of a project"))?;
-
    let profile = ctx.profile()?;
-
    let signer = term::signer(&profile)?;
-
    let repository = profile.storage.repository(id)?;
-
    let _project = repository
-
        .identity_doc()
-
        .context(format!("couldn't load project {id} from local state"))?;
-
    let mut patches = Patches::open(&repository)?;
-

-
    let (patch_id, revision) = if options.revision {
-
        let id = options.id.resolve::<git::Oid>(&repository.backend)?;
-
        let (patch_id, _, rev_id, rev) = patches
-
            .find_by_revision(&RevisionId::from(id))?
-
            .ok_or_else(|| anyhow!("revision {} does not exist", id))?;
-

-
        (patch_id, Some((rev_id, rev)))
-
    } else {
-
        let id = options.id.resolve::<PatchId>(&repository.backend)?;
-
        (id, None)
-
    };
-

-
    let mut patch = patches
-
        .get_mut(&patch_id)
-
        .context(format!("couldn't find patch {patch_id} locally"))?;
-

-
    let (revision_id, revision) = if let Some(v) = revision {
-
        v
-
    } else {
-
        let (id, r) = patch.latest();
-
        (id, r.clone())
-
    };
-

-
    let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
-
    match options.op {
-
        Operation::Review {
-
            verdict,
-
            by_hunk,
-
            unified,
-
            hunk,
-
        } if by_hunk => {
-
            let mut opts = git::raw::DiffOptions::new();
-
            opts.patience(true)
-
                .minimal(true)
-
                .context_lines(unified as u32);
-

-
            builder::ReviewBuilder::new(patch_id, *profile.id(), &repository)
-
                .hunk(hunk)
-
                .verdict(verdict)
-
                .run(&revision, &mut opts)?;
-
        }
-
        Operation::Review { verdict, .. } => {
-
            let message = options.message.get(REVIEW_HELP_MSG)?;
-
            let message = message.replace(REVIEW_HELP_MSG.trim(), "");
-
            let message = if message.is_empty() {
-
                None
-
            } else {
-
                Some(message)
-
            };
-
            patch.review(revision_id, verdict, message, vec![], &signer)?;
-

-
            match verdict {
-
                Some(Verdict::Accept) => {
-
                    term::success!(
-
                        "Patch {} {}",
-
                        patch_id_pretty,
-
                        term::format::highlight("accepted")
-
                    );
-
                }
-
                Some(Verdict::Reject) => {
-
                    term::success!(
-
                        "Patch {} {}",
-
                        patch_id_pretty,
-
                        term::format::negative("rejected")
-
                    );
-
                }
-
                None => {
-
                    term::success!("Patch {} reviewed", patch_id_pretty);
-
                }
-
            }
-
        }
-
        Operation::Delete => {
-
            let name = git::refs::storage::draft::review(profile.id(), &patch_id);
-

-
            match repository.backend.find_reference(&name) {
-
                Ok(mut r) => r.delete()?,
-
                Err(e) => {
-
                    anyhow::bail!("Couldn't delete review reference '{name}': {e}");
-
                }
-
            }
-
        }
-
    }
-

-
    if options.sync {
-
        term::warning("the `--sync` option is not yet supported");
-
    }
-

-
    Ok(())
-
}
deleted radicle-cli/src/commands/review/builder.rs
@@ -1,389 +0,0 @@
-
//! Review builder.
-
//!
-
//! This module enables a user to review a patch by interactively viewing and accepting diff hunks.
-
//! The interaction and output is modeled around `git add -p`.
-
//!
-
//! To implement this behavior, we keep a hidden Git tree object that tracks the state of the
-
//! repository including the accepted hunks. Thus, every time a diff hunk is accepted, it is applied
-
//! to that tree. We call that tree the "brain", as it tracks what the code reviewer has reviewed.
-
//!
-
//! The brain starts out equalling the tree of the base branch, and eventually, when the brain
-
//! matches the tree of the patch being reviewed (by accepting hunks), we can say that the patch has
-
//! been fully reviewed.
-
//!
-
use std::collections::VecDeque;
-
use std::io::IsTerminal as _;
-
use std::str::FromStr;
-
use std::{fmt, io};
-

-
use radicle::cob::patch::{PatchId, Revision, Verdict};
-
use radicle::git;
-
use radicle::prelude::*;
-
use radicle::storage::git::Repository;
-
use radicle_surf::diff::*;
-

-
use crate::git::unified_diff;
-
use crate::terminal as term;
-

-
/// Help message shown to user.
-
const HELP: &str = "\
-
y - accept this hunk
-
n - ignore this hunk
-
c - comment on this hunk
-
j - leave this hunk undecided, see next hunk
-
k - leave this hunk undecided, see previous hunk
-
s - split the current hunk into smaller hunks
-
q - quit; do not accept this hunk nor any of the remaining ones
-
? - print help";
-

-
/// The actions that a user can carry out on a review item.
-
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
-
pub enum ReviewAction {
-
    Accept,
-
    Ignore,
-
    Comment,
-
    Split,
-
    Next,
-
    Previous,
-
    Help,
-
    Quit,
-
}
-

-
impl ReviewAction {
-
    /// Ask the user what action to take.
-
    fn prompt(
-
        mut input: impl io::BufRead,
-
        mut output: impl io::Write,
-
        prompt: impl fmt::Display,
-
    ) -> io::Result<Option<Self>> {
-
        write!(&mut output, "{prompt} ")?;
-

-
        let mut s = String::new();
-
        input.read_line(&mut s)?;
-

-
        if s.trim().is_empty() {
-
            return Ok(None);
-
        }
-
        Self::from_str(s.trim())
-
            .map(Some)
-
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
-
    }
-
}
-

-
impl std::fmt::Display for ReviewAction {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            Self::Accept => write!(f, "y"),
-
            Self::Ignore => write!(f, "n"),
-
            Self::Comment => write!(f, "c"),
-
            Self::Split => write!(f, "s"),
-
            Self::Next => write!(f, "j"),
-
            Self::Previous => write!(f, "k"),
-
            Self::Help => write!(f, "?"),
-
            Self::Quit => write!(f, "q"),
-
        }
-
    }
-
}
-

-
impl FromStr for ReviewAction {
-
    type Err = io::Error;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        match s {
-
            "y" => Ok(Self::Accept),
-
            "n" => Ok(Self::Ignore),
-
            "c" => Ok(Self::Comment),
-
            "s" => Ok(Self::Split),
-
            "j" => Ok(Self::Next),
-
            "k" => Ok(Self::Previous),
-
            "?" => Ok(Self::Help),
-
            "q" => Ok(Self::Quit),
-
            _ => Err(io::Error::new(
-
                io::ErrorKind::InvalidInput,
-
                format!("invalid action '{s}'"),
-
            )),
-
        }
-
    }
-
}
-

-
/// A single review item. Can be a hunk or eg. a file move.
-
pub struct ReviewItem<'a> {
-
    file: &'a FileDiff,
-
    hunk: Option<&'a Hunk<Modification>>,
-
}
-

-
/// Queue of items (usually hunks) left to review.
-
#[derive(Default)]
-
pub struct ReviewQueue<'a> {
-
    queue: VecDeque<(usize, ReviewItem<'a>)>,
-
}
-

-
impl<'a> ReviewQueue<'a> {
-
    /// Push an item to the queue.
-
    fn push(&mut self, file: &'a FileDiff, hunks: Option<&'a Hunks<Modification>>) {
-
        let mut queue_item = |hunk| {
-
            self.queue
-
                .push_back((self.queue.len(), ReviewItem { file, hunk }))
-
        };
-

-
        if let Some(hunks) = hunks {
-
            for hunk in hunks.iter() {
-
                queue_item(Some(hunk));
-
            }
-
        } else {
-
            queue_item(None);
-
        }
-
    }
-
}
-

-
impl<'a> std::ops::Deref for ReviewQueue<'a> {
-
    type Target = VecDeque<(usize, ReviewItem<'a>)>;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.queue
-
    }
-
}
-

-
impl<'a> std::ops::DerefMut for ReviewQueue<'a> {
-
    fn deref_mut(&mut self) -> &mut Self::Target {
-
        &mut self.queue
-
    }
-
}
-

-
impl<'a> Iterator for ReviewQueue<'a> {
-
    type Item = (usize, ReviewItem<'a>);
-

-
    fn next(&mut self) -> Option<Self::Item> {
-
        self.queue.pop_front()
-
    }
-
}
-

-
/// Builds a patch review interactively.
-
pub struct ReviewBuilder<'a> {
-
    /// Patch being reviewed.
-
    patch_id: PatchId,
-
    /// Where the review draft is being stored.
-
    refname: git::Namespaced<'a>,
-
    /// Stored copy of repository.
-
    repo: &'a Repository,
-
    /// Single hunk review.
-
    hunk: Option<usize>,
-
    /// Verdict for review items.
-
    verdict: Option<Verdict>,
-
}
-

-
impl<'a> ReviewBuilder<'a> {
-
    /// Create a new review builder.
-
    pub fn new(patch_id: PatchId, nid: NodeId, repo: &'a Repository) -> Self {
-
        Self {
-
            patch_id,
-
            refname: git::refs::storage::draft::review(&nid, &patch_id),
-
            repo,
-
            hunk: None,
-
            verdict: None,
-
        }
-
    }
-

-
    /// Review a single hunk. Set to `None` to review all hunks.
-
    pub fn hunk(mut self, hunk: Option<usize>) -> Self {
-
        self.hunk = hunk;
-
        self
-
    }
-

-
    /// Give this verdict to all review items. Set to `None` to not give a verdict.
-
    pub fn verdict(mut self, verdict: Option<Verdict>) -> Self {
-
        self.verdict = verdict;
-
        self
-
    }
-

-
    /// Run the review builder for the given revision.
-
    pub fn run(self, revision: &Revision, opts: &mut git::raw::DiffOptions) -> anyhow::Result<()> {
-
        let repo = self.repo.raw();
-
        let base = repo.find_commit((*revision.base()).into())?;
-
        let author = repo.signature()?;
-
        let patch_id = self.patch_id;
-
        let review = if let Ok(c) = self.current() {
-
            term::success!(
-
                "Loaded existing review {} for patch {}",
-
                term::format::secondary(term::format::parens(term::format::oid(c.id()))),
-
                term::format::tertiary(&patch_id)
-
            );
-
            c
-
        } else {
-
            let oid = repo.commit(
-
                Some(self.refname.as_str()),
-
                &author,
-
                &author,
-
                &format!("Review {patch_id}"),
-
                &base.tree()?,
-
                &[&base],
-
            )?;
-
            repo.find_commit(oid)?
-
        };
-

-
        let mut writer = unified_diff::Writer::new(io::stdout()).styled(true);
-
        let mut queue = ReviewQueue::default(); // Queue of hunks to review.
-
        let mut current = None; // File of the current hunk.
-
        let mut stdin = io::stdin().lock();
-
        let mut stderr = io::stderr().lock();
-

-
        let commit = repo.find_commit(revision.head().into())?;
-
        let tree = commit.tree()?;
-
        let brain = review.tree()?;
-

-
        let mut find_opts = git::raw::DiffFindOptions::new();
-
        find_opts.exact_match_only(true);
-
        find_opts.all(true);
-
        find_opts.copies(false); // We don't support finding copies at the moment.
-

-
        let mut diff = repo.diff_tree_to_tree(Some(&brain), Some(&tree), Some(opts))?;
-
        diff.find_similar(Some(&mut find_opts))?;
-

-
        if diff.deltas().next().is_none() {
-
            term::success!("All hunks have been reviewed");
-
            return Ok(());
-
        }
-
        let diff = Diff::try_from(diff)?;
-

-
        for file in diff.files() {
-
            match file {
-
                FileDiff::Modified(f) => match &f.diff {
-
                    DiffContent::Plain { hunks, .. } => queue.push(file, Some(hunks)),
-
                    DiffContent::Binary => queue.push(file, None),
-
                    DiffContent::Empty => {}
-
                },
-
                FileDiff::Added(f) => match &f.diff {
-
                    DiffContent::Plain { hunks, .. } => queue.push(file, Some(hunks)),
-
                    DiffContent::Binary => queue.push(file, None),
-
                    DiffContent::Empty => {}
-
                },
-
                FileDiff::Deleted(f) => match &f.diff {
-
                    DiffContent::Plain { hunks, .. } => queue.push(file, Some(hunks)),
-
                    DiffContent::Binary => queue.push(file, None),
-
                    DiffContent::Empty => {}
-
                },
-
                FileDiff::Moved(_) => queue.push(file, None),
-
                FileDiff::Copied(_) => {
-
                    // Copies are not supported and should never be generated due to the diff
-
                    // options we pass.
-
                    panic!("ReviewBuilder::by_hunk: copy diffs are not supported");
-
                }
-
            }
-
        }
-
        let total = queue.len();
-

-
        while let Some((ix, item)) = queue.next() {
-
            if let Some(hunk) = self.hunk {
-
                if hunk != ix + 1 {
-
                    continue;
-
                }
-
            }
-
            let progress = term::format::secondary(format!("({}/{total})", ix + 1));
-
            let ReviewItem { file, hunk } = item;
-

-
            if current.map_or(true, |c| c != file) {
-
                writer.encode(&unified_diff::FileHeader::from(file))?;
-
                current = Some(file);
-
            }
-
            if let Some(h) = hunk {
-
                writer.encode(h)?;
-
            }
-

-
            match self.prompt(&mut stdin, &mut stderr, progress) {
-
                Some(ReviewAction::Accept) => {
-
                    let mut buf = Vec::new();
-
                    {
-
                        let mut writer = unified_diff::Writer::new(&mut buf);
-

-
                        writer.encode(&unified_diff::FileHeader::from(file))?;
-
                        if let Some(h) = hunk {
-
                            writer.encode(h)?;
-
                        }
-
                    }
-
                    let diff = git::raw::Diff::from_buffer(&buf)?;
-

-
                    let mut index = repo.apply_to_tree(&brain, &diff, None)?;
-
                    let brain = index.write_tree_to(repo)?;
-
                    let brain = repo.find_tree(brain)?;
-

-
                    let _oid =
-
                        review.amend(Some(&self.refname), None, None, None, None, Some(&brain))?;
-
                }
-
                Some(ReviewAction::Ignore) => {
-
                    // Do nothing. Hunk will be reviewable again next time.
-
                }
-
                Some(ReviewAction::Comment) => {
-
                    eprintln!(
-
                        "{}",
-
                        term::format::tertiary("Commenting is not yet implemented").bold()
-
                    );
-
                    queue.push_front((ix, item));
-
                }
-
                Some(ReviewAction::Split) => {
-
                    eprintln!(
-
                        "{}",
-
                        term::format::tertiary("Splitting is not yet implemented").bold()
-
                    );
-
                    queue.push_front((ix, item));
-
                }
-
                Some(ReviewAction::Next) => {
-
                    queue.push_back((ix, item));
-
                }
-
                Some(ReviewAction::Previous) => {
-
                    queue.push_front((ix, item));
-

-
                    if let Some(e) = queue.pop_back() {
-
                        queue.push_front(e);
-
                    }
-
                }
-
                Some(ReviewAction::Quit) => {
-
                    break;
-
                }
-
                Some(ReviewAction::Help) => {
-
                    eprintln!("{}", term::format::tertiary(HELP).bold());
-
                    queue.push_front((ix, item));
-
                }
-
                None => {
-
                    eprintln!(
-
                        "{}",
-
                        term::format::secondary(format!(
-
                            "{} hunk(s) remaining to review",
-
                            queue.len() + 1
-
                        ))
-
                    );
-
                    queue.push_front((ix, item));
-
                }
-
            }
-
        }
-

-
        Ok(())
-
    }
-

-
    fn prompt(
-
        &self,
-
        mut input: impl io::BufRead,
-
        mut output: &mut io::StderrLock,
-
        progress: impl fmt::Display,
-
    ) -> Option<ReviewAction> {
-
        if let Some(v) = self.verdict {
-
            match v {
-
                Verdict::Accept => Some(ReviewAction::Accept),
-
                Verdict::Reject => Some(ReviewAction::Ignore),
-
            }
-
        } else if output.is_terminal() {
-
            let prompt = term::format::secondary("Accept this hunk? [y,n,c,j,k,?]").bold();
-

-
            ReviewAction::prompt(&mut input, &mut output, format!("{progress} {prompt}"))
-
                .unwrap_or(Some(ReviewAction::Help))
-
        } else {
-
            Some(ReviewAction::Ignore)
-
        }
-
    }
-

-
    fn current(&self) -> Result<git::raw::Commit, git::raw::Error> {
-
        self.repo
-
            .raw()
-
            .find_reference(&self.refname)?
-
            .peel_to_commit()
-
    }
-
}
modified radicle-cli/src/main.rs
@@ -208,13 +208,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "review" => {
-
            term::run_command_args::<rad_review::Options, _>(
-
                rad_review::HELP,
-
                rad_review::run,
-
                args.to_vec(),
-
            );
-
        }
        "clean" => {
            term::run_command_args::<rad_clean::Options, _>(
                rad_clean::HELP,