Implement `rad-patch`

Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
Alexis Sellier 2022-11-15 16:22:02 +01:00
parent dea268c389
commit 47d20003c0
No known key found for this signature in database
16 changed files with 853 additions and 14 deletions

7
Cargo.lock generated
View File

@ -1579,6 +1579,7 @@ dependencies = [
"radicle-crypto",
"serde_json",
"thiserror",
"timeago",
"zeroize",
]
@ -2242,6 +2243,12 @@ dependencies = [
"time-core",
]
[[package]]
name = "timeago"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ec32dde57efb15c035ac074118d7f32820451395f28cb0524a01d4e94983b26"
[[package]]
name = "tinyvec"
version = "1.6.0"

View File

@ -16,6 +16,7 @@ lexopt = { version = "0.2" }
log = { version = "0.4", features = ["std"] }
serde_json = { version = "1" }
thiserror = { version = "1" }
timeago = { version = "0.3", default-features = false }
zeroize = { version = "1.1" }
[dependencies.radicle]

View File

@ -14,6 +14,8 @@ pub mod rad_init;
pub mod rad_inspect;
#[path = "commands/ls.rs"]
pub mod rad_ls;
#[path = "commands/patch.rs"]
pub mod rad_patch;
#[path = "commands/push.rs"]
pub mod rad_push;
#[path = "commands/rm.rs"]

View File

@ -51,7 +51,7 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let Ok(repo) = storage.repository(id) else { return };
let Ok((_, head)) = repo.head() else { return };
let Ok(Doc { payload, .. }) = repo.project_of(profile.id()) else { return };
let head = term::format::oid(&head);
let head = term::format::oid(head);
table.push([
term::format::bold(payload.name),
term::format::tertiary(id),

View File

@ -0,0 +1,158 @@
#[path = "patch/common.rs"]
mod common;
#[path = "patch/create.rs"]
mod create;
#[path = "patch/list.rs"]
mod list;
use std::ffi::OsString;
use std::str::FromStr;
use anyhow::anyhow;
use radicle::cob::patch::PatchId;
use radicle::prelude::*;
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::patch::Comment;
pub const HELP: Help = Help {
name: "patch",
description: "Work with radicle patches",
version: env!("CARGO_PKG_VERSION"),
usage: r#"
Usage
rad patch [<option>...]
Create options
-u, --update [<id>] Update an existing patch (default: no)
--[no-]sync Sync patch to seed (default: sync)
--[no-]push Push patch head to storage (default: true)
-m, --message [<string>] Provide a comment message to the patch or revision (default: prompt)
--no-message Leave the patch or revision comment message blank
Options
-l, --list List all patches (default: false)
--help Print help
"#,
};
#[derive(Debug)]
pub enum Update {
No,
Any,
Patch(PatchId),
}
impl Default for Update {
fn default() -> Self {
Self::No
}
}
#[derive(Default, Debug)]
pub struct Options {
pub list: bool,
pub verbose: bool,
pub sync: bool,
pub push: bool,
pub update: Update,
pub message: Comment,
}
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 list = false;
let mut verbose = false;
let mut sync = true;
let mut message = Comment::default();
let mut push = true;
let mut update = Update::default();
while let Some(arg) = parser.next()? {
match arg {
// Operations.
Long("list") | Short('l') => {
list = true;
}
Long("update") | Short('u') => {
if let Ok(val) = parser.value() {
let val = val
.to_str()
.ok_or_else(|| anyhow!("patch id specified is not UTF-8"))?;
let id = PatchId::from_str(val)
.map_err(|_| anyhow!("invalid patch id '{}'", val))?;
update = Update::Patch(id);
} else {
update = Update::Any;
}
}
// Options.
Long("message") | Short('m') => {
let txt: String = parser.value()?.to_string_lossy().into();
message.append(&txt);
}
Long("no-message") => {
message = Comment::Blank;
}
Long("sync") => {
sync = true;
}
Long("no-sync") => {
sync = false;
}
Long("push") => {
push = true;
}
Long("no-push") => {
push = false;
}
// Common.
Long("verbose") | Short('v') => {
verbose = true;
}
Long("help") => {
return Err(Error::Help.into());
}
_ => return Err(anyhow::anyhow!(arg.unexpected())),
}
}
Ok((
Options {
list,
sync,
message,
push,
update,
verbose,
},
vec![],
))
}
}
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let (workdir, id) = radicle::rad::cwd()
.map_err(|_| anyhow!("this command must be run in the context of a project"))?;
let profile = ctx.profile()?;
let storage = profile.storage.repository(id)?;
if options.list {
list::run(&storage, &profile, Some(workdir), options)?;
} else {
create::run(&storage, &profile, &workdir, options)?;
}
Ok(())
}

View File

@ -0,0 +1,162 @@
use radicle::cob::patch::{MergeTarget, Patch, PatchId, PatchStore};
use radicle::git;
use radicle::git::raw::Oid;
use radicle::prelude::*;
use radicle::storage::git::Repository;
use radicle::storage::Remote;
use crate::terminal as term;
use crate::terminal::args::Error;
/// List of merge targets.
#[derive(Debug, Default)]
pub struct MergeTargets {
/// Merge targets that have already merged the patch.
pub merged: Vec<Remote>,
/// Merge targets that haven't merged the patch.
pub not_merged: Vec<(Remote, git::Oid)>,
}
/// Find potential merge targets for the given head.
pub fn find_merge_targets(
head: &Oid,
branch: &git::RefStr,
storage: &Repository,
) -> anyhow::Result<MergeTargets> {
let mut targets = MergeTargets::default();
for remote in storage.remotes()? {
let (_, remote) = remote?;
let Some(target_oid) = remote.refs.head(branch) else {
continue;
};
if is_merged(storage.raw(), target_oid.into(), *head)? {
targets.merged.push(remote);
} else {
targets.not_merged.push((remote, target_oid));
}
}
Ok(targets)
}
/// Return the [`Oid`] of the merge target.
pub fn patch_merge_target_oid(target: MergeTarget, repository: &Repository) -> anyhow::Result<Oid> {
match target {
MergeTarget::Delegates => {
if let Ok((_, target)) = repository.head() {
Ok(*target)
} else {
anyhow::bail!(
"failed to determine default branch head for project {}",
repository.id,
);
}
}
}
}
/// Create a human friendly message about git's sync status.
pub fn pretty_sync_status(
repo: &git::raw::Repository,
revision_oid: Oid,
head_oid: Oid,
) -> anyhow::Result<String> {
let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?;
if a == 0 && b == 0 {
return Ok(term::format::dim("up to date"));
}
let ahead = term::format::positive(a);
let behind = term::format::negative(b);
Ok(format!("ahead {}, behind {}", ahead, behind))
}
/// Make a human friendly string for commit version information.
///
/// For example '<oid> (branch1[, branch2])'.
pub fn pretty_commit_version(
revision_oid: &Oid,
repo: &Option<git::raw::Repository>,
) -> anyhow::Result<String> {
let mut oid = term::format::secondary(term::format::oid(*revision_oid));
let mut branches: Vec<String> = vec![];
if let Some(repo) = repo {
for r in repo.references()?.flatten() {
if !r.is_branch() {
continue;
}
if let (Some(oid), Some(name)) = (&r.target(), &r.shorthand()) {
if oid == revision_oid {
branches.push(name.to_string());
};
};
}
};
if !branches.is_empty() {
oid = format!(
"{} {}",
oid,
term::format::yellow(format!("({})", branches.join(", "))),
);
}
Ok(oid)
}
/// Find patches with a merge base equal to the one provided.
pub fn find_unmerged_with_base(
patch_head: Oid,
target_head: Oid,
merge_base: Oid,
patches: &PatchStore,
workdir: &git::raw::Repository,
) -> anyhow::Result<Vec<(PatchId, Patch)>> {
// My patches.
let proposed: Vec<_> = patches.proposed_by(patches.public_key())?.collect();
let mut matches = Vec::new();
for (id, patch) in proposed {
let (_, rev) = patch.latest();
if !rev.merges.is_empty() {
continue;
}
if **patch.head() == patch_head {
continue;
}
// Merge-base between the two patches.
if workdir.merge_base(**patch.head(), target_head)? == merge_base {
matches.push((id, patch));
}
}
Ok(matches)
}
/// Return commits between the merge base and a head.
pub fn patch_commits<'a>(
repo: &'a git::raw::Repository,
base: &Oid,
head: &Oid,
) -> anyhow::Result<Vec<git::raw::Commit<'a>>> {
let mut commits = Vec::new();
let mut revwalk = repo.revwalk()?;
revwalk.push_range(&format!("{}..{}", base, head))?;
for rev in revwalk {
let commit = repo.find_commit(rev?)?;
commits.push(commit);
}
Ok(commits)
}
/// Check whether a commit has been merged into a target branch.
pub fn is_merged(repo: &git::raw::Repository, target: Oid, commit: Oid) -> Result<bool, Error> {
if let Ok(base) = repo.merge_base(target, commit) {
Ok(base == commit)
} else {
Ok(false)
}
}

View File

@ -0,0 +1,307 @@
use std::path::Path;
use anyhow::{anyhow, Context};
use radicle::cob::patch::{MergeTarget, Patch, PatchId, PatchStore};
use radicle::git;
use radicle::git::raw::Oid;
use radicle::prelude::*;
use radicle::storage::git::Repository;
use crate::terminal as term;
use crate::terminal::args::Error;
use super::common;
use super::{Options, Update};
const PATCH_MSG: &str = r#"
<!--
Please enter a patch message for your changes. An empty
message aborts the patch proposal.
The first line is the patch title. The patch description
follows, and must be separated with a blank line, just
like a commit message. Markdown is supported in the title
and description.
-->
"#;
const REVISION_MSG: &str = r#"
<!--
Please enter a comment message for your patch update. Leaving this
blank is also okay.
-->
"#;
/// Run patch creation.
pub fn run(
storage: &Repository,
profile: &Profile,
workdir: &git::raw::Repository,
options: Options,
) -> anyhow::Result<()> {
let project = storage.project_of(&profile.public_key).context(format!(
"couldn't load project {} from local state",
storage.id
))?;
term::headline(&format!(
"🌱 Creating patch for {}",
term::format::highlight(&project.name)
));
let signer = term::signer(profile)?;
let cobs = radicle::cob::Store::open(profile.public_key, storage)?;
let patches = cobs.patches();
// `HEAD`; This is what we are proposing as a patch.
let head = workdir.head()?;
let head_oid = head.target().ok_or(anyhow!("invalid HEAD ref; aborting"))?;
let head_commit = workdir.find_commit(head_oid)?;
let head_branch = head
.shorthand()
.ok_or(anyhow!("cannot create patch from detached head; aborting"))?;
// Make sure the `HEAD` commit can be found in the monorepo. Otherwise there
// is no way for anyone to merge this patch.
let mut spinner = term::spinner(format!(
"Looking for HEAD ({}) in storage...",
term::format::secondary(term::format::oid(head_oid))
));
if storage.commit(head_oid.into()).is_err() {
if !options.push {
spinner.failed();
term::blank();
return Err(Error::WithHint {
err: anyhow!("Current branch head was not found in storage"),
hint: "hint: run `git push rad` and try again",
}
.into());
}
spinner.message("Pushing HEAD to storage...");
let output = git::run::<_, _, &str, &str>(Path::new("."), ["push", "rad"], [])?;
if options.verbose {
spinner.finish();
term::blob(output);
}
}
spinner.finish();
// Determine the merge target for this patch. This can ben any tracked remote's "default"
// branch, as well as your own (eg. `rad/master`).
let mut spinner = term::spinner("Analyzing remotes...");
let targets =
common::find_merge_targets(&head_oid, project.default_branch.as_refstr(), storage)?;
// eg. `refs/namespaces/<peer>/refs/heads/master`
let (target_peer, target_oid) = match targets.not_merged.as_slice() {
[] => {
spinner.message("All tracked peers are up to date.");
return Ok(());
}
[target] => target,
_ => {
// TODO: Let user select which branch to use as a target.
todo!();
}
};
// TODO: Tell user how many peers don't have this change.
spinner.finish();
// TODO: Handle case where `rad/master` isn't up to date with the target.
// In that case we should warn the user that their master branch is not up
// to date, and error out, unless the user specifies manually the merge
// base.
// The merge base is basically the commit at which the histories diverge.
let base_oid = workdir.merge_base((*target_oid).into(), head_oid)?;
let commits = common::patch_commits(workdir, &base_oid, &head_oid)?;
let patch = match &options.update {
Update::No => None,
Update::Any => {
let mut spinner = term::spinner("Finding patches to update...");
let mut result = common::find_unmerged_with_base(
head_oid,
**target_oid,
base_oid,
&patches,
workdir,
)?;
if let Some((id, patch)) = result.pop() {
if result.is_empty() {
spinner.message(format!(
"Found existing patch {} {}",
term::format::tertiary(term::format::cob(&id)),
term::format::italic(&patch.title)
));
spinner.finish();
term::blank();
Some((id, patch))
} else {
spinner.failed();
term::blank();
anyhow::bail!("More than one patch available to update, please specify an id with `rad patch --update <id>`");
}
} else {
spinner.failed();
term::blank();
anyhow::bail!("No patches found that share a base, please create a new patch or specify the patch id manually");
}
}
Update::Patch(id) => {
if let Some(patch) = patches.get(id)? {
Some((*id, patch))
} else {
anyhow::bail!("Patch `{}` not found", id);
}
}
};
if let Some((id, patch)) = patch {
if term::confirm("Update?") {
term::blank();
return update(
patch, id, &base_oid, &head_oid, &patches, workdir, options, &signer,
);
} else {
anyhow::bail!("Patch update aborted by user");
}
}
// TODO: List matching working copy refs for all targets.
term::blank();
term::info!(
"{}/{} ({}) <- {}/{} ({})",
term::format::dim(target_peer.id),
term::format::highlight(&project.default_branch.to_string()),
term::format::secondary(&term::format::oid(*target_oid)),
term::format::dim(term::format::node(cobs.public_key())),
term::format::highlight(&head_branch.to_string()),
term::format::secondary(&term::format::oid(head_oid)),
);
// TODO: Test case where the target branch has been re-written passed the merge-base, since the fork was created
// This can also happen *after* the patch is created.
term::patch::print_commits_ahead_behind(workdir, head_oid, (*target_oid).into())?;
// List commits in patch that aren't in the target branch.
term::blank();
term::patch::list_commits(&commits)?;
term::blank();
if !term::confirm("Continue?") {
anyhow::bail!("patch proposal aborted by user");
}
let message = head_commit
.message()
.ok_or(anyhow!("commit summary is not valid UTF-8; aborting"))?;
let message = options.message.get(&format!("{}{}", message, PATCH_MSG));
let (title, description) = message.split_once("\n\n").unwrap_or((&message, ""));
let (title, description) = (title.trim(), description.trim());
let description = description.replace(PATCH_MSG.trim(), ""); // Delete help message.
if title.is_empty() {
anyhow::bail!("a title must be given");
}
let title_pretty = &term::format::dim(format!("╭─ {} ───────", title));
term::blank();
term::print(title_pretty);
term::blank();
if description.is_empty() {
term::print(term::format::italic("No description provided."));
} else {
term::markdown(&description);
}
term::blank();
term::print(&term::format::dim(format!(
"╰{}",
"".repeat(term::text_width(title_pretty) - 1)
)));
term::blank();
if !term::confirm("Create patch?") {
anyhow::bail!("patch proposal aborted by user");
}
let id = patches.create(
title,
&description,
MergeTarget::default(),
base_oid,
head_oid,
&[],
&signer,
)?;
term::blank();
term::success!("Patch {} created 🌱", term::format::highlight(id));
if options.sync {
// TODO
}
Ok(())
}
/// Update an existing patch with a new revision.
fn update<G: Signer>(
patch: Patch,
patch_id: PatchId,
base: &Oid,
head: &Oid,
patches: &PatchStore,
workdir: &git::raw::Repository,
options: Options,
signer: &G,
) -> anyhow::Result<()> {
let (current, current_revision) = patch.latest();
if *current_revision.oid == *head {
term::info!("Nothing to do, patch is already up to date.");
return Ok(());
}
term::info!(
"{} {} ({}) -> {} ({})",
term::format::tertiary(term::format::cob(&patch_id)),
term::format::dim(format!("R{}", current)),
term::format::secondary(term::format::oid(current_revision.oid)),
term::format::dim(format!("R{}", current + 1)),
term::format::secondary(term::format::oid(*head)),
);
let message = options.message.get(REVISION_MSG);
// Difference between the two revisions.
term::patch::print_commits_ahead_behind(workdir, *head, *current_revision.oid)?;
term::blank();
if !term::confirm("Continue?") {
anyhow::bail!("patch update aborted by user");
}
let new = patches.update(&patch_id, message, *base, *head, signer)?;
assert_eq!(new, current + 1);
term::blank();
term::success!("Patch {} updated 🌱", term::format::highlight(patch_id));
term::blank();
if options.sync {
// TODO
}
Ok(())
}

View File

@ -0,0 +1,167 @@
use radicle::cob;
use radicle::cob::patch::{Patch, PatchId, Verdict};
use radicle::git;
use radicle::prelude::*;
use radicle::profile::Profile;
use radicle::storage::git::Repository;
use crate::terminal as term;
use super::common;
use super::Options;
/// List patches.
pub fn run(
storage: &Repository,
profile: &Profile,
workdir: Option<git::raw::Repository>,
options: Options,
) -> anyhow::Result<()> {
if options.sync {
// TODO: Sync
}
let me = *profile.id();
let cobs = cob::Store::open(*profile.id(), storage)?;
let patches = cobs.patches();
let proposed = patches.proposed()?;
// Patches the user authored.
let mut own = Vec::new();
// Patches other users authored.
let mut other = Vec::new();
for (id, patch) in proposed {
if *patch.author.id() == me {
own.push((id, patch));
} else {
other.push((id, patch));
}
}
term::blank();
term::print(&term::format::badge_positive("YOU PROPOSED"));
if own.is_empty() {
term::blank();
term::print(&term::format::italic("Nothing to show."));
} else {
for (id, patch) in &mut own {
term::blank();
print(&me, id, patch, &workdir, storage)?;
}
}
term::blank();
term::print(&term::format::badge_secondary("OTHERS PROPOSED"));
if other.is_empty() {
term::blank();
term::print(&term::format::italic("Nothing to show."));
} else {
for (id, patch) in &mut other {
term::blank();
print(cobs.public_key(), id, patch, &workdir, storage)?;
}
}
term::blank();
Ok(())
}
/// Print patch details.
fn print(
whoami: &PublicKey,
patch_id: &PatchId,
patch: &Patch,
workdir: &Option<git::raw::Repository>,
storage: &Repository,
) -> anyhow::Result<()> {
let target_head = common::patch_merge_target_oid(patch.target, storage)?;
let you = patch.author.id() == whoami;
let prefix = "└─ ";
let mut author_info = vec![format!(
"{}* opened by {}",
prefix,
term::format::tertiary(patch.author.id()),
)];
if you {
author_info.push(term::format::secondary("(you)"));
}
author_info.push(term::format::dim(term::format::timestamp(&patch.timestamp)));
let revision = patch.revisions.last();
term::info!(
"{} {} {} {} {}",
term::format::bold(&patch.title),
term::format::highlight(term::format::cob(patch_id)),
term::format::dim(format!("R{}", patch.version())),
common::pretty_commit_version(&revision.oid, workdir)?,
common::pretty_sync_status(storage.raw(), *revision.oid, target_head)?,
);
term::info!("{}", author_info.join(" "));
let mut timeline = Vec::new();
for merge in &revision.merges {
let peer = storage.remote(&merge.node)?;
let mut badges = Vec::new();
if peer.delegate {
badges.push(term::format::secondary("(delegate)"));
}
if peer.id == *whoami {
badges.push(term::format::secondary("(you)"));
}
timeline.push((
merge.timestamp,
format!(
"{}{} by {} {}",
" ".repeat(term::text_width(prefix)),
term::format::secondary(term::format::dim("✓ merged")),
term::format::tertiary(peer.id),
badges.join(" "),
),
));
}
for review in revision.reviews.values() {
let verdict = match review.verdict {
Some(Verdict::Accept) => term::format::positive(term::format::dim("✓ accepted")),
Some(Verdict::Reject) => term::format::negative(term::format::dim("✗ rejected")),
None => term::format::negative(term::format::dim("⋄ reviewed")),
};
let peer = storage.remote(review.author.id())?;
let mut badges = Vec::new();
if peer.delegate {
badges.push(term::format::secondary("(delegate)"));
}
if peer.id == *whoami {
badges.push(term::format::secondary("(you)"));
}
timeline.push((
review.timestamp,
format!(
"{}{} by {} {}",
" ".repeat(term::text_width(prefix)),
verdict,
term::format::tertiary(review.author.id()),
badges.join(" "),
),
));
}
timeline.sort_by_key(|(t, _)| *t);
for (time, event) in timeline.iter().rev() {
term::info!(
"{} {}",
event,
term::format::dim(term::format::timestamp(time))
);
}
Ok(())
}

View File

@ -1,4 +1,6 @@
#![allow(clippy::collapsible_if)]
#![allow(clippy::or_fun_call)]
#![allow(clippy::too_many_arguments)]
pub mod commands;
pub mod git;
pub mod project;

View File

@ -1,6 +1,7 @@
use std::fmt;
use std::{fmt, time};
use dialoguer::console::style;
use radicle::cob::Timestamp;
use radicle::node::NodeId;
use radicle::profile::Profile;
use radicle_cob::ObjectId;
@ -17,8 +18,8 @@ pub fn node(node: &NodeId) -> String {
}
/// Format a git Oid.
pub fn oid(oid: &radicle::git::Oid) -> String {
format!("{:.7}", oid)
pub fn oid(oid: impl Into<radicle::git::Oid>) -> String {
format!("{:.7}", oid.into())
}
/// Format a COB id.
@ -26,6 +27,15 @@ pub fn cob(id: &ObjectId) -> String {
format!("{:.11}", id.to_string())
}
/// Format a timestamp.
pub fn timestamp(time: &Timestamp) -> String {
let fmt = timeago::Formatter::new();
let now = Timestamp::now();
let duration = time::Duration::from_secs(now.as_secs() - time.as_secs());
fmt.convert(duration)
}
/// Identity formatter that takes a profile and displays it as
/// `<node-id> (<username>)` depending on the configuration.
pub struct Identity<'a> {

View File

@ -56,7 +56,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
.summary_bytes()
.unwrap_or_else(|| commit.message_bytes());
table.push([
term::format::secondary(term::format::oid(&commit.id().into())),
term::format::secondary(term::format::oid(commit.id())),
term::format::italic(String::from_utf8_lossy(message)),
]);
}

View File

@ -8,14 +8,15 @@ pub mod transaction;
pub mod value;
pub use cob::{
identity, object::collaboration::error, CollaborativeObject, Create, Entry, History, ObjectId,
TypeName, Update,
identity, object::collaboration::error, CollaborativeObject, Contents, Create, Entry, History,
ObjectId, TypeName, Update,
};
pub use shared::Timestamp;
pub use store::Store;
use radicle_cob as cob;
use radicle_git_ext::Oid;
pub use radicle_cob::*;
use crate::{
identity::{project::Identity, Did},
node::NodeId,

View File

@ -111,6 +111,11 @@ impl<'a, T> Store<'a, T> {
pub fn author(&self) -> Author {
Author::new(self.whoami)
}
/// Get the public key associated with this store.
pub fn public_key(&self) -> &PublicKey {
&self.whoami
}
}
impl<'a, T: FromHistory> Store<'a, T> {

View File

@ -18,6 +18,7 @@ use crate::storage::refs::SignedRefs;
use crate::storage::{BranchName, ReadRepository as _, RemoteId, WriteRepository as _};
use crate::{identity, storage};
/// Name of the radicle storage remote.
pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad"));
/// Radicle remote name for peer, eg. `rad/<node-id>`
@ -346,14 +347,15 @@ mod tests {
use radicle_crypto::test::signer::MockSigner;
use super::*;
use crate::git::fmt::refname;
use crate::git::{name::component, qualified, Qualified};
use crate::identity::{Delegate, Did};
use crate::storage::git::transport;
use crate::storage::git::Storage;
use crate::storage::{ReadStorage, WriteStorage};
use crate::test::fixtures;
use super::*;
#[test]
fn test_init() {
let tempdir = tempfile::tempdir().unwrap();
@ -387,6 +389,7 @@ mod tests {
let (_, head) = project_repo.head().unwrap();
// Test canonical refs.
assert_eq!(refs.head(&component!("master")).unwrap(), head);
assert_eq!(project_repo.raw().refname_to_id("HEAD").unwrap(), *head);
assert_eq!(
project_repo
@ -439,8 +442,11 @@ mod tests {
let bob_remote = storage.repository(id).unwrap().remote(bob_id).unwrap();
assert_eq!(
bob_remote.refs.get(&refname!("master")),
alice_refs.get(&refname!("master"))
bob_remote
.refs
.get(&qualified!("refs/heads/master"))
.unwrap(),
alice_refs.get(&qualified!("refs/heads/master")).unwrap()
);
}

View File

@ -185,7 +185,7 @@ impl<V> From<Remotes<V>> for HashMap<RemoteId, Refs> {
/// A project remote.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Remote<V> {
pub struct Remote<V = Verified> {
/// ID of remote.
pub id: PublicKey,
/// Git references published under this remote, and their hashes.

View File

@ -101,6 +101,17 @@ impl Refs {
})
}
/// Get a particular ref.
pub fn get(&self, name: &git::Qualified) -> Option<Oid> {
self.0.get(name.to_ref_string().as_refstr()).copied()
}
/// Get a particular head ref.
pub fn head(&self, name: impl AsRef<git::RefStr>) -> Option<Oid> {
let branch = git::refname!("refs/heads").join(name);
self.0.get(&branch).copied()
}
/// Create refs from a canonical representation.
pub fn from_canonical(bytes: &[u8]) -> Result<Self, canonical::Error> {
let reader = BufReader::new(bytes);