cob: Remove CRDTs from COB state
It turns out that the CRDT formed by the union of Git DAGs is enough to guarantee everything we need for COBs. This changes the following things: * COB operations no longer need to be commutative * COB histories are traversed in the same deterministic order on all replicas * It's now possible to implement RSMs on top of COBs, eg. scripting * Lamport clocks have been removed * `radicle-crdt` is no longer a dependency of `radicle` * COBs are no longer instances of `Semilattice` * The `Ops` type was removed in favor of having `Op` contain multiple actions
This commit is contained in:
parent
8a61aece01
commit
f639192dc6
|
|
@ -379,7 +379,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
let mut timestamped = Vec::new();
|
let mut timestamped = Vec::new();
|
||||||
let mut no_latest = Vec::new();
|
let mut no_latest = Vec::new();
|
||||||
for result in proposals.all()? {
|
for result in proposals.all()? {
|
||||||
let (id, proposal, _) = result?;
|
let (id, proposal) = result?;
|
||||||
match proposal.latest() {
|
match proposal.latest() {
|
||||||
None => no_latest.push((id, proposal)),
|
None => no_latest.push((id, proposal)),
|
||||||
Some((_, revision)) => {
|
Some((_, revision)) => {
|
||||||
|
|
@ -490,7 +490,7 @@ fn select<'a>(
|
||||||
let revision = proposal
|
let revision = proposal
|
||||||
.revision(&id)
|
.revision(&id)
|
||||||
.context(format!("No revision found for {id}"))?
|
.context(format!("No revision found for {id}"))?
|
||||||
.get()
|
.as_ref()
|
||||||
.context(format!("Revision {id} was redacted"))?;
|
.context(format!("Revision {id} was redacted"))?;
|
||||||
(id, revision)
|
(id, revision)
|
||||||
}
|
}
|
||||||
|
|
@ -524,7 +524,7 @@ fn commit_select<'a>(
|
||||||
let revision = proposal
|
let revision = proposal
|
||||||
.revision(&id)
|
.revision(&id)
|
||||||
.context(format!("No revision found for {id}"))?
|
.context(format!("No revision found for {id}"))?
|
||||||
.get()
|
.as_ref()
|
||||||
.context(format!("Revision {id} was redacted"))?;
|
.context(format!("Revision {id} was redacted"))?;
|
||||||
(id, revision)
|
(id, revision)
|
||||||
}
|
}
|
||||||
|
|
@ -623,7 +623,7 @@ fn print(
|
||||||
Some(rid) => proposal
|
Some(rid) => proposal
|
||||||
.revision(rid)
|
.revision(rid)
|
||||||
.context(format!("No revision found for {rid}"))?
|
.context(format!("No revision found for {rid}"))?
|
||||||
.get()
|
.as_ref()
|
||||||
.context(format!("Revision {rid} was redacted"))?,
|
.context(format!("Revision {rid} was redacted"))?,
|
||||||
};
|
};
|
||||||
print_meta(proposal.title(), proposal.description(), proposal.state());
|
print_meta(proposal.title(), proposal.description(), proposal.state());
|
||||||
|
|
|
||||||
|
|
@ -381,7 +381,7 @@ fn list<R: WriteRepository + cob::Store>(
|
||||||
|
|
||||||
let mut all = Vec::new();
|
let mut all = Vec::new();
|
||||||
for result in issues.all()? {
|
for result in issues.all()? {
|
||||||
let Ok((id, issue, _)) = result else {
|
let Ok((id, issue)) = result else {
|
||||||
// Skip issues that failed to load.
|
// Skip issues that failed to load.
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ pub fn run(
|
||||||
|
|
||||||
let mut all = Vec::new();
|
let mut all = Vec::new();
|
||||||
for patch in patches.all()? {
|
for patch in patches.all()? {
|
||||||
let Ok((id, patch, _)) = patch else {
|
let Ok((id, patch)) = patch else {
|
||||||
// Skip patches that failed to load.
|
// Skip patches that failed to load.
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -91,19 +91,17 @@ impl ChangeGraph {
|
||||||
let manifest = root_node.manifest.clone();
|
let manifest = root_node.manifest.clone();
|
||||||
let graph = self
|
let graph = self
|
||||||
.graph
|
.graph
|
||||||
.fold(&root, Dag::new(), |mut graph, _, change, depth| {
|
.fold(&root, Dag::new(), |mut graph, _, change, _| {
|
||||||
// Check the change signatures are valid.
|
// Check the change signatures are valid.
|
||||||
if !change.valid_signatures() {
|
if !change.valid_signatures() {
|
||||||
return ControlFlow::Break(graph);
|
return ControlFlow::Break(graph);
|
||||||
}
|
}
|
||||||
let clock = depth as u64 + 1;
|
|
||||||
let entry = Entry::new(
|
let entry = Entry::new(
|
||||||
*change.id(),
|
*change.id(),
|
||||||
change.signature.key,
|
change.signature.key,
|
||||||
change.resource,
|
change.resource,
|
||||||
change.contents().clone(),
|
change.contents().clone(),
|
||||||
change.timestamp,
|
change.timestamp,
|
||||||
clock,
|
|
||||||
);
|
);
|
||||||
let id = *entry.id();
|
let id = *entry.id();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use radicle_crypto::PublicKey;
|
||||||
use radicle_dag::Dag;
|
use radicle_dag::Dag;
|
||||||
|
|
||||||
pub mod entry;
|
pub mod entry;
|
||||||
pub use entry::{Clock, Contents, Entry, EntryId, Timestamp};
|
pub use entry::{Contents, Entry, EntryId, Timestamp};
|
||||||
|
|
||||||
/// The DAG of changes making up the history of a collaborative object.
|
/// The DAG of changes making up the history of a collaborative object.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -51,7 +51,6 @@ impl History {
|
||||||
resource,
|
resource,
|
||||||
contents,
|
contents,
|
||||||
timestamp,
|
timestamp,
|
||||||
clock: 1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -60,16 +59,6 @@ impl History {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current value of the logical clock.
|
|
||||||
/// This is the maximum value of all tips.
|
|
||||||
pub fn clock(&self) -> Clock {
|
|
||||||
self.graph
|
|
||||||
.tips()
|
|
||||||
.map(|(_, node)| node.clock)
|
|
||||||
.max()
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current history timestamp.
|
/// Get the current history timestamp.
|
||||||
/// This is the latest timestamp of any tip.
|
/// This is the latest timestamp of any tip.
|
||||||
pub fn timestamp(&self) -> Timestamp {
|
pub fn timestamp(&self) -> Timestamp {
|
||||||
|
|
@ -127,14 +116,8 @@ impl History {
|
||||||
{
|
{
|
||||||
let tips = self.tips();
|
let tips = self.tips();
|
||||||
let new_id = new_id.into();
|
let new_id = new_id.into();
|
||||||
let new_entry = Entry::new(
|
let new_entry = Entry::new(new_id, new_actor, new_resource, new_contents, new_timestamp);
|
||||||
new_id,
|
|
||||||
new_actor,
|
|
||||||
new_resource,
|
|
||||||
new_contents,
|
|
||||||
new_timestamp,
|
|
||||||
self.clock() + 1,
|
|
||||||
);
|
|
||||||
self.graph.node(new_id, new_entry);
|
self.graph.node(new_id, new_entry);
|
||||||
|
|
||||||
for tip in tips {
|
for tip in tips {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@ use crate::{object, ObjectId};
|
||||||
/// This is the change payload.
|
/// This is the change payload.
|
||||||
pub type Contents = NonEmpty<Vec<u8>>;
|
pub type Contents = NonEmpty<Vec<u8>>;
|
||||||
|
|
||||||
/// Logical clock used to track causality in change graph.
|
|
||||||
pub type Clock = u64;
|
|
||||||
|
|
||||||
/// Local time in seconds since epoch.
|
/// Local time in seconds since epoch.
|
||||||
pub type Timestamp = u64;
|
pub type Timestamp = u64;
|
||||||
|
|
||||||
|
|
@ -93,8 +90,6 @@ pub struct Entry {
|
||||||
pub(super) contents: Contents,
|
pub(super) contents: Contents,
|
||||||
/// The entry timestamp, as seconds since epoch.
|
/// The entry timestamp, as seconds since epoch.
|
||||||
pub(super) timestamp: Timestamp,
|
pub(super) timestamp: Timestamp,
|
||||||
/// Logical clock.
|
|
||||||
pub(super) clock: Clock,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Entry {
|
impl Entry {
|
||||||
|
|
@ -104,7 +99,6 @@ impl Entry {
|
||||||
resource: Oid,
|
resource: Oid,
|
||||||
contents: Contents,
|
contents: Contents,
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
clock: Clock,
|
|
||||||
) -> Self
|
) -> Self
|
||||||
where
|
where
|
||||||
Id: Into<EntryId>,
|
Id: Into<EntryId>,
|
||||||
|
|
@ -115,7 +109,6 @@ impl Entry {
|
||||||
resource,
|
resource,
|
||||||
contents,
|
contents,
|
||||||
timestamp,
|
timestamp,
|
||||||
clock,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,9 +136,4 @@ impl Entry {
|
||||||
pub fn id(&self) -> &EntryId {
|
pub fn id(&self) -> &EntryId {
|
||||||
&self.id
|
&self.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Logical clock.
|
|
||||||
pub fn clock(&self) -> Clock {
|
|
||||||
self.clock
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ pub(crate) fn patch(
|
||||||
"discussions": rev.discussion().comments()
|
"discussions": rev.discussion().comments()
|
||||||
.map(|(id, comment)| Comment::new(id, comment, aliases))
|
.map(|(id, comment)| Comment::new(id, comment, aliases))
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
"timestamp": rev.timestamp(),
|
"timestamp": rev.timestamp().as_secs().to_string(),
|
||||||
"reviews": rev.reviews().map(|(nid, _review)| review(nid, aliases.alias(nid), _review)).collect::<Vec<_>>(),
|
"reviews": rev.reviews().map(|(nid, _review)| review(nid, aliases.alias(nid), _review)).collect::<Vec<_>>(),
|
||||||
})
|
})
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
|
|
@ -165,7 +165,7 @@ fn merge(merge: &Merge, nid: &NodeId, alias: Option<Alias>) -> Value {
|
||||||
"alias": alias,
|
"alias": alias,
|
||||||
},
|
},
|
||||||
"commit": merge.commit,
|
"commit": merge.commit,
|
||||||
"timestamp": merge.timestamp,
|
"timestamp": merge.timestamp.as_secs().to_string(),
|
||||||
"revision": merge.revision,
|
"revision": merge.revision,
|
||||||
}),
|
}),
|
||||||
None => json!({
|
None => json!({
|
||||||
|
|
@ -173,7 +173,7 @@ fn merge(merge: &Merge, nid: &NodeId, alias: Option<Alias>) -> Value {
|
||||||
"id": nid,
|
"id": nid,
|
||||||
},
|
},
|
||||||
"commit": merge.commit,
|
"commit": merge.commit,
|
||||||
"timestamp": merge.timestamp,
|
"timestamp": merge.timestamp.as_secs().to_string(),
|
||||||
"revision": merge.revision,
|
"revision": merge.revision,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +190,7 @@ fn review(nid: &NodeId, alias: Option<Alias>, review: &Review) -> Value {
|
||||||
"verdict": review.verdict(),
|
"verdict": review.verdict(),
|
||||||
"summary": review.summary(),
|
"summary": review.summary(),
|
||||||
"comments": review.comments().collect::<Vec<_>>(),
|
"comments": review.comments().collect::<Vec<_>>(),
|
||||||
"timestamp": review.timestamp(),
|
"timestamp": review.timestamp().as_secs().to_string(),
|
||||||
}),
|
}),
|
||||||
None => json!({
|
None => json!({
|
||||||
"author": {
|
"author": {
|
||||||
|
|
@ -199,7 +199,7 @@ fn review(nid: &NodeId, alias: Option<Alias>, review: &Review) -> Value {
|
||||||
"verdict": review.verdict(),
|
"verdict": review.verdict(),
|
||||||
"summary": review.summary(),
|
"summary": review.summary(),
|
||||||
"comments": review.comments().collect::<Vec<_>>(),
|
"comments": review.comments().collect::<Vec<_>>(),
|
||||||
"timestamp": review.timestamp(),
|
"timestamp": review.timestamp().as_secs().to_string(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,6 +240,7 @@ struct Comment<'a> {
|
||||||
author: Value,
|
author: Value,
|
||||||
body: &'a str,
|
body: &'a str,
|
||||||
reactions: Vec<(&'a ActorId, &'a Reaction)>,
|
reactions: Vec<(&'a ActorId, &'a Reaction)>,
|
||||||
|
#[serde(with = "radicle::serde_ext::localtime::time")]
|
||||||
timestamp: Timestamp,
|
timestamp: Timestamp,
|
||||||
reply_to: Option<CommentId>,
|
reply_to: Option<CommentId>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -460,16 +460,16 @@ async fn issues_handler(
|
||||||
let mut issues: Vec<_> = issues
|
let mut issues: Vec<_> = issues
|
||||||
.all()?
|
.all()?
|
||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
let (id, issue, clock) = r.ok()?;
|
let (id, issue) = r.ok()?;
|
||||||
(state.matches(issue.state())).then_some((id, issue, clock))
|
(state.matches(issue.state())).then_some((id, issue))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
issues.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp()));
|
issues.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
|
||||||
let aliases = &ctx.profile.aliases();
|
let aliases = &ctx.profile.aliases();
|
||||||
let issues = issues
|
let issues = issues
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, issue, _)| api::json::issue(id, issue, aliases))
|
.map(|(id, issue)| api::json::issue(id, issue, aliases))
|
||||||
.skip(page * per_page)
|
.skip(page * per_page)
|
||||||
.take(per_page)
|
.take(per_page)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
@ -739,15 +739,15 @@ async fn patches_handler(
|
||||||
let mut patches = patches
|
let mut patches = patches
|
||||||
.all()?
|
.all()?
|
||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
let (id, patch, clock) = r.ok()?;
|
let (id, patch) = r.ok()?;
|
||||||
(state.matches(patch.state())).then_some((id, patch, clock))
|
(state.matches(patch.state())).then_some((id, patch))
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
patches.sort_by(|(_, a, _), (_, b, _)| b.timestamp().cmp(&a.timestamp()));
|
patches.sort_by(|(_, a), (_, b)| b.timestamp().cmp(&a.timestamp()));
|
||||||
let aliases = ctx.profile.aliases();
|
let aliases = ctx.profile.aliases();
|
||||||
let patches = patches
|
let patches = patches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, patch, _)| api::json::patch(id, patch, &repo, &aliases))
|
.map(|(id, patch)| api::json::patch(id, patch, &repo, &aliases))
|
||||||
.skip(page * per_page)
|
.skip(page * per_page)
|
||||||
.take(per_page)
|
.take(per_page)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ pub const ISSUE_ID: &str = "5ad77fa3f476beed9a26f49b2b3b844e61bef792";
|
||||||
pub const ISSUE_DISCUSSION_ID: &str = "f1dff128a22e8183a23516dd9812e72e80914c92";
|
pub const ISSUE_DISCUSSION_ID: &str = "f1dff128a22e8183a23516dd9812e72e80914c92";
|
||||||
pub const ISSUE_COMMENT_ID: &str = "845218041bf9eb8155bfa4aaa8f0c91ce18e5c13";
|
pub const ISSUE_COMMENT_ID: &str = "845218041bf9eb8155bfa4aaa8f0c91ce18e5c13";
|
||||||
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
|
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
|
||||||
pub const TIMESTAMP: u64 = 1671125284;
|
pub const TIMESTAMP: &str = "1671125284";
|
||||||
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
|
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
|
||||||
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
|
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
|
||||||
pub const CONTRIBUTOR_NID: &str = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
|
pub const CONTRIBUTOR_NID: &str = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
|
||||||
|
|
@ -97,7 +97,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
|
||||||
|
|
||||||
let workdir = dir.join("hello-world");
|
let workdir = dir.join("hello-world");
|
||||||
|
|
||||||
env::set_var("RAD_COMMIT_TIME", TIMESTAMP.to_string());
|
env::set_var("RAD_COMMIT_TIME", TIMESTAMP);
|
||||||
|
|
||||||
fs::create_dir_all(&workdir).unwrap();
|
fs::create_dir_all(&workdir).unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,11 @@ use localtime::{LocalDuration, LocalTime};
|
||||||
use log::*;
|
use log::*;
|
||||||
|
|
||||||
use crate::crypto::Signer;
|
use crate::crypto::Signer;
|
||||||
use crate::git::raw as git;
|
|
||||||
use crate::prelude::{Address, Id};
|
use crate::prelude::{Address, Id};
|
||||||
use crate::service::io::Io;
|
use crate::service::io::Io;
|
||||||
use crate::service::{DisconnectReason, Event, Message, NodeId};
|
use crate::service::{DisconnectReason, Event, Message, NodeId};
|
||||||
|
use crate::storage::WriteStorage;
|
||||||
use crate::storage::{Namespaces, RefUpdate};
|
use crate::storage::{Namespaces, RefUpdate};
|
||||||
use crate::storage::{WriteRepository, WriteStorage};
|
|
||||||
use crate::test::peer::Service;
|
use crate::test::peer::Service;
|
||||||
use crate::worker::FetchError;
|
use crate::worker::FetchError;
|
||||||
use crate::Link;
|
use crate::Link;
|
||||||
|
|
@ -413,15 +412,19 @@ impl<S: WriteStorage + 'static, G: Signer> Simulation<S, G> {
|
||||||
}
|
}
|
||||||
Input::Fetched(rid, nid, result) => {
|
Input::Fetched(rid, nid, result) => {
|
||||||
let result = Rc::try_unwrap(result).unwrap();
|
let result = Rc::try_unwrap(result).unwrap();
|
||||||
let mut repo = match p.storage().repository_mut(rid) {
|
let repo = match p.storage().repository_mut(rid) {
|
||||||
Ok(repo) => repo,
|
Ok(repo) => repo,
|
||||||
Err(e) if e.is_not_found() => p.storage().create(rid).unwrap(),
|
Err(e) if e.is_not_found() => p.storage().create(rid).unwrap(),
|
||||||
Err(e) => panic!("Failed to open repository: {e}"),
|
Err(e) => panic!("Failed to open repository: {e}"),
|
||||||
};
|
};
|
||||||
match &result {
|
match &result {
|
||||||
Ok((_, remotes)) => {
|
Ok((_, remotes)) => {
|
||||||
fetch(&mut repo, &nid, Namespaces::Trusted(remotes.clone()))
|
radicle::test::fetch(
|
||||||
.unwrap();
|
&repo,
|
||||||
|
&nid,
|
||||||
|
Namespaces::Trusted(remotes.clone()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
Err(err) => panic!("Error fetching: {err}"),
|
Err(err) => panic!("Error fetching: {err}"),
|
||||||
}
|
}
|
||||||
|
|
@ -673,56 +676,3 @@ impl<S: WriteStorage + 'static, G: Signer> Simulation<S, G> {
|
||||||
self.partitions.contains(&(a, b)) || self.partitions.contains(&(b, a))
|
self.partitions.contains(&(a, b)) || self.partitions.contains(&(b, a))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform a fetch between two local repositories.
|
|
||||||
/// This has the same outcome as doing a "real" fetch, but suffices for the simulation, and
|
|
||||||
/// doesn't require running nodes.
|
|
||||||
fn fetch<W: WriteRepository>(
|
|
||||||
repo: &mut W,
|
|
||||||
node: &NodeId,
|
|
||||||
namespaces: impl Into<Namespaces>,
|
|
||||||
) -> Result<Vec<RefUpdate>, radicle::storage::FetchError> {
|
|
||||||
let namespace = match namespaces.into() {
|
|
||||||
Namespaces::All => None,
|
|
||||||
Namespaces::Trusted(trusted) => trusted.into_iter().next(),
|
|
||||||
};
|
|
||||||
let mut updates = Vec::new();
|
|
||||||
let mut callbacks = git::RemoteCallbacks::new();
|
|
||||||
let mut opts = git::FetchOptions::default();
|
|
||||||
let refspec = if let Some(namespace) = namespace {
|
|
||||||
opts.prune(git::FetchPrune::On);
|
|
||||||
format!("refs/namespaces/{namespace}/refs/*:refs/namespaces/{namespace}/refs/*")
|
|
||||||
} else {
|
|
||||||
opts.prune(git::FetchPrune::Off);
|
|
||||||
"refs/namespaces/*:refs/namespaces/*".to_owned()
|
|
||||||
};
|
|
||||||
|
|
||||||
callbacks.update_tips(|name, old, new| {
|
|
||||||
if let Ok(name) = radicle::git::RefString::try_from(name) {
|
|
||||||
if name.to_namespaced().is_some() {
|
|
||||||
updates.push(RefUpdate::from(name, old, new));
|
|
||||||
// Returning `true` ensures the process is not aborted.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
});
|
|
||||||
opts.remote_callbacks(callbacks);
|
|
||||||
|
|
||||||
let mut remote = repo.raw().remote_anonymous(
|
|
||||||
radicle::storage::git::transport::remote::Url {
|
|
||||||
node: *node,
|
|
||||||
repo: repo.id(),
|
|
||||||
namespace,
|
|
||||||
}
|
|
||||||
.to_string()
|
|
||||||
.as_str(),
|
|
||||||
)?;
|
|
||||||
remote.fetch(&[refspec], Some(&mut opts), None)?;
|
|
||||||
drop(opts);
|
|
||||||
|
|
||||||
repo.validate()?;
|
|
||||||
repo.set_head()?;
|
|
||||||
|
|
||||||
Ok(updates)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -452,12 +452,12 @@ fn patch_merge<G: Signer>(
|
||||||
|
|
||||||
let mut patches = patch::Patches::open(stored)?;
|
let mut patches = patch::Patches::open(stored)?;
|
||||||
for patch in patches.all()? {
|
for patch in patches.all()? {
|
||||||
let (id, patch, clock) = patch?;
|
let (id, patch) = patch?;
|
||||||
let (revision_id, revision) = patch.latest();
|
let (revision_id, revision) = patch.latest();
|
||||||
|
|
||||||
if patch.is_open() && commits.contains(&revision.head()) {
|
if patch.is_open() && commits.contains(&revision.head()) {
|
||||||
let revision_id = *revision_id;
|
let revision_id = *revision_id;
|
||||||
let mut patch = patch::PatchMut::new(id, patch, clock, &mut patches);
|
let mut patch = patch::PatchMut::new(id, patch, &mut patches);
|
||||||
|
|
||||||
patch.merge(revision_id, new, signer)?;
|
patch.merge(revision_id, new, signer)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ pub fn all(repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
|
||||||
|
|
||||||
Ok(patches
|
Ok(patches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, issue, _)| (id, issue))
|
.map(|(id, issue)| (id, issue))
|
||||||
.collect::<Vec<_>>())
|
.collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ pub fn all(repository: &Repository) -> Result<Vec<(PatchId, Patch)>> {
|
||||||
|
|
||||||
Ok(patches
|
Ok(patches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, patch, _)| (id, patch))
|
.map(|(id, patch)| (id, patch))
|
||||||
.collect::<Vec<_>>())
|
.collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,6 @@ features = ["vendored-libgit2"]
|
||||||
path = "../radicle-cob"
|
path = "../radicle-cob"
|
||||||
version = "0"
|
version = "0"
|
||||||
|
|
||||||
[dependencies.radicle-crdt]
|
|
||||||
path = "../radicle-crdt"
|
|
||||||
version = "0"
|
|
||||||
|
|
||||||
[dependencies.radicle-crypto]
|
[dependencies.radicle-crypto]
|
||||||
path = "../radicle-crypto"
|
path = "../radicle-crypto"
|
||||||
version = "0"
|
version = "0"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
use std::fmt::{self, Display};
|
use std::fmt::{self, Display};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use localtime::LocalTime;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
pub use radicle_crdt::clock;
|
/// Timestamp used for COB operations.
|
||||||
pub use radicle_crdt::clock::Physical as Timestamp;
|
pub type Timestamp = LocalTime;
|
||||||
|
|
||||||
/// Author.
|
/// Author.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::{ops::Deref, str::FromStr};
|
use std::{ops::Deref, str::FromStr};
|
||||||
|
|
||||||
use crypto::{PublicKey, Signature};
|
use crypto::{PublicKey, Signature};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use radicle_cob::{ObjectId, TypeName};
|
use radicle_cob::{ObjectId, TypeName};
|
||||||
use radicle_crdt::{clock, GMap, GSet, LWWMap, LWWReg, Max, Redactable, Semilattice};
|
|
||||||
use radicle_crypto::{Signer, Verified};
|
use radicle_crypto::{Signer, Verified};
|
||||||
use radicle_git_ext::Oid;
|
use radicle_git_ext::Oid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -12,8 +12,8 @@ use thiserror::Error;
|
||||||
use crate::{
|
use crate::{
|
||||||
cob::{
|
cob::{
|
||||||
self,
|
self,
|
||||||
common::Timestamp,
|
|
||||||
store::{self, FromHistory as _, HistoryAction, Transaction},
|
store::{self, FromHistory as _, HistoryAction, Transaction},
|
||||||
|
Timestamp,
|
||||||
},
|
},
|
||||||
identity::{doc::DocError, Did, Identity, IdentityError},
|
identity::{doc::DocError, Did, Identity, IdentityError},
|
||||||
prelude::{Doc, ReadRepository},
|
prelude::{Doc, ReadRepository},
|
||||||
|
|
@ -25,9 +25,6 @@ use super::{
|
||||||
Author, EntryId,
|
Author, EntryId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The logical clock we use to order operations to proposals.
|
|
||||||
pub use clock::Lamport as Clock;
|
|
||||||
|
|
||||||
/// Type name of an identity proposal.
|
/// Type name of an identity proposal.
|
||||||
pub static TYPENAME: Lazy<TypeName> =
|
pub static TYPENAME: Lazy<TypeName> =
|
||||||
Lazy::new(|| FromStr::from_str("xyz.radicle.id.proposal").expect("type name is valid"));
|
Lazy::new(|| FromStr::from_str("xyz.radicle.id.proposal").expect("type name is valid"));
|
||||||
|
|
@ -141,18 +138,18 @@ pub enum Error {
|
||||||
/// Once a proposal has reached the quourum threshold for the previous
|
/// Once a proposal has reached the quourum threshold for the previous
|
||||||
/// [`Identity`] then it may be committed to the person's local
|
/// [`Identity`] then it may be committed to the person's local
|
||||||
/// storage using [`Proposal::commit`].
|
/// storage using [`Proposal::commit`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct Proposal {
|
pub struct Proposal {
|
||||||
/// Title of the proposal.
|
/// Title of the proposal.
|
||||||
title: LWWReg<Max<String>>,
|
title: String,
|
||||||
/// Description of the proposal.
|
/// Description of the proposal.
|
||||||
description: LWWReg<Max<String>>,
|
description: String,
|
||||||
/// Current state of the proposal.
|
/// Current state of the proposal.
|
||||||
state: LWWReg<Max<State>>,
|
state: State,
|
||||||
/// List of revisions for this proposal.
|
/// List of revisions for this proposal.
|
||||||
revisions: GMap<RevisionId, Redactable<Revision>>,
|
revisions: BTreeMap<RevisionId, Option<Revision>>,
|
||||||
/// Timeline of events.
|
/// Timeline of events.
|
||||||
timeline: GSet<(clock::Lamport, EntryId)>,
|
timeline: Vec<EntryId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
|
@ -164,25 +161,6 @@ pub enum State {
|
||||||
Committed,
|
Committed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Semilattice for Proposal {
|
|
||||||
fn merge(&mut self, other: Self) {
|
|
||||||
self.description.merge(other.description);
|
|
||||||
self.revisions.merge(other.revisions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Proposal {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
title: LWWReg::initial(Max::from(String::default())),
|
|
||||||
description: LWWReg::initial(Max::from(String::default())),
|
|
||||||
state: LWWReg::initial(Max::from(State::default())),
|
|
||||||
revisions: GMap::default(),
|
|
||||||
timeline: GSet::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Proposal {
|
impl Proposal {
|
||||||
/// Commit the [`Doc`], found at the given `revision`, to the
|
/// Commit the [`Doc`], found at the given `revision`, to the
|
||||||
/// provided `remote`.
|
/// provided `remote`.
|
||||||
|
|
@ -213,7 +191,7 @@ impl Proposal {
|
||||||
let revision = self
|
let revision = self
|
||||||
.revision(rid)
|
.revision(rid)
|
||||||
.ok_or_else(|| CommitError::Missing(*rid))?
|
.ok_or_else(|| CommitError::Missing(*rid))?
|
||||||
.get()
|
.as_ref()
|
||||||
.ok_or_else(|| CommitError::Redacted(*rid))?;
|
.ok_or_else(|| CommitError::Redacted(*rid))?;
|
||||||
let doc = &revision.proposed;
|
let doc = &revision.proposed;
|
||||||
let previous = Identity::load(signer.public_key(), repo)?;
|
let previous = Identity::load(signer.public_key(), repo)?;
|
||||||
|
|
@ -264,29 +242,29 @@ impl Proposal {
|
||||||
|
|
||||||
/// The most recent title for the proposal.
|
/// The most recent title for the proposal.
|
||||||
pub fn title(&self) -> &str {
|
pub fn title(&self) -> &str {
|
||||||
self.title.get().get()
|
&self.title
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The most recent description for the proposal, if present.
|
/// The most recent description for the proposal, if present.
|
||||||
pub fn description(&self) -> Option<&str> {
|
pub fn description(&self) -> Option<&str> {
|
||||||
Some(self.description.get().get())
|
Some(self.description.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn state(&self) -> &State {
|
pub fn state(&self) -> &State {
|
||||||
self.state.get().get()
|
&self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A specific [`Revision`], that may be redacted.
|
/// A specific [`Revision`], that may be redacted.
|
||||||
pub fn revision(&self, revision: &RevisionId) -> Option<&Redactable<Revision>> {
|
pub fn revision(&self, revision: &RevisionId) -> Option<&Option<Revision>> {
|
||||||
self.revisions.get(revision)
|
self.revisions.get(revision)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All the [`Revision`]s that have not been redacted.
|
/// All the [`Revision`]s that have not been redacted.
|
||||||
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
|
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
|
||||||
self.timeline.iter().filter_map(|(_, id)| {
|
self.timeline.iter().filter_map(|id| {
|
||||||
self.revisions
|
self.revisions
|
||||||
.get(id)
|
.get(id)
|
||||||
.and_then(Redactable::get)
|
.and_then(|o| o.as_ref())
|
||||||
.map(|rev| (id, rev))
|
.map(|rev| (id, rev))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -321,45 +299,41 @@ impl store::FromHistory for Proposal {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply<R: ReadRepository>(
|
fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Self::Error> {
|
||||||
&mut self,
|
let id = op.id;
|
||||||
ops: impl IntoIterator<Item = Op>,
|
let author = Author::new(op.author);
|
||||||
repo: &R,
|
let timestamp = op.timestamp;
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
for op in ops {
|
|
||||||
let id = op.id;
|
|
||||||
let author = Author::new(op.author);
|
|
||||||
let timestamp = op.timestamp;
|
|
||||||
|
|
||||||
self.timeline.insert((op.clock, id));
|
debug_assert!(!self.timeline.contains(&op.id));
|
||||||
|
|
||||||
match op.action {
|
self.timeline.push(id);
|
||||||
|
|
||||||
|
for action in op.actions {
|
||||||
|
match action {
|
||||||
Action::Accept {
|
Action::Accept {
|
||||||
revision,
|
revision,
|
||||||
signature,
|
signature,
|
||||||
} => match self.revisions.get_mut(&revision) {
|
} => match self.revisions.get_mut(&revision) {
|
||||||
Some(Redactable::Present(revision)) => {
|
Some(Some(revision)) => revision.accept(op.author, signature),
|
||||||
revision.accept(op.author, signature, op.clock)
|
Some(None) => return Err(ApplyError::Redacted(revision)),
|
||||||
}
|
|
||||||
Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
|
|
||||||
None => return Err(ApplyError::Missing(revision)),
|
None => return Err(ApplyError::Missing(revision)),
|
||||||
},
|
},
|
||||||
Action::Close => self.state.set(State::Closed, op.clock),
|
Action::Close => self.state = State::Closed,
|
||||||
Action::Edit { title, description } => {
|
Action::Edit { title, description } => {
|
||||||
self.title.set(title, op.clock);
|
self.title = title;
|
||||||
self.description.set(description, op.clock);
|
self.description = description;
|
||||||
}
|
}
|
||||||
Action::Commit => self.state.set(State::Committed, op.clock),
|
Action::Commit => self.state = State::Committed,
|
||||||
Action::Redact { revision } => {
|
Action::Redact { revision } => {
|
||||||
if let Some(revision) = self.revisions.get_mut(&revision) {
|
if let Some(revision) = self.revisions.get_mut(&revision) {
|
||||||
revision.merge(Redactable::Redacted);
|
*revision = None;
|
||||||
} else {
|
} else {
|
||||||
return Err(ApplyError::Missing(revision));
|
return Err(ApplyError::Missing(revision));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Reject { revision } => match self.revisions.get_mut(&revision) {
|
Action::Reject { revision } => match self.revisions.get_mut(&revision) {
|
||||||
Some(Redactable::Present(revision)) => revision.reject(op.author, op.clock),
|
Some(Some(revision)) => revision.reject(op.author),
|
||||||
Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
|
Some(None) => return Err(ApplyError::Redacted(revision)),
|
||||||
None => return Err(ApplyError::Missing(revision)),
|
None => return Err(ApplyError::Missing(revision)),
|
||||||
},
|
},
|
||||||
Action::Revision { current, proposed } => {
|
Action::Revision { current, proposed } => {
|
||||||
|
|
@ -371,23 +345,16 @@ impl store::FromHistory for Proposal {
|
||||||
}
|
}
|
||||||
self.revisions.insert(
|
self.revisions.insert(
|
||||||
id,
|
id,
|
||||||
Redactable::Present(Revision::new(author, current, proposed, timestamp)),
|
Some(Revision::new(author.clone(), current, proposed, timestamp)),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Action::Thread { revision, action } => match self.revisions.get_mut(&revision) {
|
Action::Thread { revision, action } => match self.revisions.get_mut(&revision) {
|
||||||
Some(Redactable::Present(revision)) => revision.discussion.apply(
|
Some(Some(revision)) => revision.discussion.apply(
|
||||||
[cob::Op::new(
|
cob::Op::new(op.id, action, op.author, op.timestamp, op.identity),
|
||||||
op.id,
|
|
||||||
action,
|
|
||||||
op.author,
|
|
||||||
op.timestamp,
|
|
||||||
op.clock,
|
|
||||||
op.identity,
|
|
||||||
)],
|
|
||||||
repo,
|
repo,
|
||||||
)?,
|
)?,
|
||||||
Some(Redactable::Redacted) => return Err(ApplyError::Redacted(revision)),
|
Some(None) => return Err(ApplyError::Redacted(revision)),
|
||||||
None => return Err(ApplyError::Missing(revision)),
|
None => return Err(ApplyError::Missing(revision)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -418,7 +385,7 @@ pub struct Revision {
|
||||||
/// Discussion thread for this revision.
|
/// Discussion thread for this revision.
|
||||||
pub discussion: Thread,
|
pub discussion: Thread,
|
||||||
/// [`Verdict`]s given by the delegates.
|
/// [`Verdict`]s given by the delegates.
|
||||||
pub verdicts: LWWMap<PublicKey, Redactable<Verdict>>,
|
pub verdicts: BTreeMap<PublicKey, Option<Verdict>>,
|
||||||
/// Physical timestamp of this proposal revision.
|
/// Physical timestamp of this proposal revision.
|
||||||
pub timestamp: Timestamp,
|
pub timestamp: Timestamp,
|
||||||
}
|
}
|
||||||
|
|
@ -435,7 +402,7 @@ impl Revision {
|
||||||
current,
|
current,
|
||||||
proposed,
|
proposed,
|
||||||
discussion: Thread::default(),
|
discussion: Thread::default(),
|
||||||
verdicts: LWWMap::default(),
|
verdicts: BTreeMap::default(),
|
||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +417,7 @@ impl Revision {
|
||||||
pub fn verdicts(&self) -> impl Iterator<Item = (&PublicKey, &Verdict)> {
|
pub fn verdicts(&self) -> impl Iterator<Item = (&PublicKey, &Verdict)> {
|
||||||
self.verdicts
|
self.verdicts
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(key, verdict)| verdict.get().map(|verdict| (key, verdict)))
|
.filter_map(|(key, verdict)| verdict.as_ref().map(|verdict| (key, verdict)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn accepted(&self) -> Vec<Did> {
|
pub fn accepted(&self) -> Vec<Did> {
|
||||||
|
|
@ -475,7 +442,7 @@ impl Revision {
|
||||||
let votes_for = self
|
let votes_for = self
|
||||||
.verdicts
|
.verdicts
|
||||||
.iter()
|
.iter()
|
||||||
.fold(0, |count, (_, verdict)| match verdict.get() {
|
.fold(0, |count, (_, verdict)| match verdict {
|
||||||
Some(Verdict::Accept(_)) => count + 1,
|
Some(Verdict::Accept(_)) => count + 1,
|
||||||
Some(Verdict::Reject) => count,
|
Some(Verdict::Reject) => count,
|
||||||
None => count,
|
None => count,
|
||||||
|
|
@ -483,14 +450,12 @@ impl Revision {
|
||||||
votes_for >= previous.doc.threshold
|
votes_for >= previous.doc.threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accept(&mut self, key: PublicKey, signature: Signature, clock: Clock) {
|
fn accept(&mut self, key: PublicKey, signature: Signature) {
|
||||||
self.verdicts
|
self.verdicts.insert(key, Some(Verdict::Accept(signature)));
|
||||||
.insert(key, Redactable::Present(Verdict::Accept(signature)), clock);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reject(&mut self, key: PublicKey, clock: Clock) {
|
fn reject(&mut self, key: PublicKey) {
|
||||||
self.verdicts
|
self.verdicts.insert(key, Some(Verdict::Reject));
|
||||||
.insert(key, Redactable::Present(Verdict::Reject), clock)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -565,7 +530,6 @@ pub struct ProposalMut<'a, 'g, R> {
|
||||||
pub id: ObjectId,
|
pub id: ObjectId,
|
||||||
|
|
||||||
proposal: Proposal,
|
proposal: Proposal,
|
||||||
clock: clock::Lamport,
|
|
||||||
store: &'g mut Proposals<'a, R>,
|
store: &'g mut Proposals<'a, R>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,15 +537,9 @@ impl<'a, 'g, R> ProposalMut<'a, 'g, R>
|
||||||
where
|
where
|
||||||
R: WriteRepository + cob::Store,
|
R: WriteRepository + cob::Store,
|
||||||
{
|
{
|
||||||
pub fn new(
|
pub fn new(id: ObjectId, proposal: Proposal, store: &'g mut Proposals<'a, R>) -> Self {
|
||||||
id: ObjectId,
|
|
||||||
proposal: Proposal,
|
|
||||||
clock: clock::Lamport,
|
|
||||||
store: &'g mut Proposals<'a, R>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
clock,
|
|
||||||
proposal,
|
proposal,
|
||||||
store,
|
store,
|
||||||
}
|
}
|
||||||
|
|
@ -597,21 +555,15 @@ where
|
||||||
G: Signer,
|
G: Signer,
|
||||||
F: FnOnce(&mut Transaction<Proposal>) -> Result<(), store::Error>,
|
F: FnOnce(&mut Transaction<Proposal>) -> Result<(), store::Error>,
|
||||||
{
|
{
|
||||||
let mut tx = Transaction::new(*signer.public_key(), self.clock);
|
let mut tx = Transaction::new(*signer.public_key());
|
||||||
operations(&mut tx)?;
|
operations(&mut tx)?;
|
||||||
let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
let (ops, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
||||||
|
|
||||||
self.proposal.apply(ops, self.store.as_ref())?;
|
self.proposal.apply(ops, self.store.as_ref())?;
|
||||||
self.clock = clock;
|
|
||||||
|
|
||||||
Ok(commit)
|
Ok(commit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the internal logical clock.
|
|
||||||
pub fn clock(&self) -> &clock::Lamport {
|
|
||||||
&self.clock
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accept a proposal revision.
|
/// Accept a proposal revision.
|
||||||
pub fn accept<G: Signer>(
|
pub fn accept<G: Signer>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -715,22 +667,20 @@ where
|
||||||
proposed: Doc<Verified>,
|
proposed: Doc<Verified>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<ProposalMut<'a, 'g, R>, Error> {
|
) -> Result<ProposalMut<'a, 'g, R>, Error> {
|
||||||
let (id, proposal, clock) =
|
let (id, proposal) =
|
||||||
Transaction::initial("Create proposal", &mut self.raw, signer, |tx| {
|
Transaction::initial("Create proposal", &mut self.raw, signer, |tx| {
|
||||||
tx.revision(current.into(), proposed)?;
|
tx.revision(current.into(), proposed)?;
|
||||||
tx.edit(title, description)?;
|
tx.edit(title, description)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
// Just a sanity check that our clock is advancing as expected.
|
|
||||||
debug_assert_eq!(clock.get(), 1);
|
|
||||||
|
|
||||||
Ok(ProposalMut::new(id, proposal, clock, self))
|
Ok(ProposalMut::new(id, proposal, self))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a proposal.
|
/// Get a proposal.
|
||||||
pub fn get(&self, id: &ObjectId) -> Result<Option<Proposal>, store::Error> {
|
pub fn get(&self, id: &ObjectId) -> Result<Option<Proposal>, store::Error> {
|
||||||
self.raw.get(id).map(|r| r.map(|(p, _)| p))
|
self.raw.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a proposal mutably.
|
/// Get a proposal mutably.
|
||||||
|
|
@ -738,14 +688,13 @@ where
|
||||||
&'g mut self,
|
&'g mut self,
|
||||||
id: &ObjectId,
|
id: &ObjectId,
|
||||||
) -> Result<ProposalMut<'a, 'g, R>, store::Error> {
|
) -> Result<ProposalMut<'a, 'g, R>, store::Error> {
|
||||||
let (proposal, clock) = self
|
let proposal = self
|
||||||
.raw
|
.raw
|
||||||
.get(id)?
|
.get(id)?
|
||||||
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
|
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
|
||||||
|
|
||||||
Ok(ProposalMut {
|
Ok(ProposalMut {
|
||||||
id: *id,
|
id: *id,
|
||||||
clock,
|
|
||||||
proposal,
|
proposal,
|
||||||
store: self,
|
store: self,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
|
@ -5,9 +6,6 @@ use once_cell::sync::Lazy;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use radicle_crdt::clock;
|
|
||||||
use radicle_crdt::{LWWReg, LWWSet, Max, Semilattice};
|
|
||||||
|
|
||||||
use crate::cob;
|
use crate::cob;
|
||||||
use crate::cob::common::{Author, Reaction, Tag, Timestamp};
|
use crate::cob::common::{Author, Reaction, Tag, Timestamp};
|
||||||
use crate::cob::store::Transaction;
|
use crate::cob::store::Transaction;
|
||||||
|
|
@ -92,42 +90,20 @@ impl State {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue state. Accumulates [`Action`].
|
/// Issue state. Accumulates [`Action`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct Issue {
|
pub struct Issue {
|
||||||
/// Actors assigned to this issue.
|
/// Actors assigned to this issue.
|
||||||
assignees: LWWSet<ActorId>,
|
assignees: BTreeSet<ActorId>,
|
||||||
/// Title of the issue.
|
/// Title of the issue.
|
||||||
title: LWWReg<Max<String>>,
|
title: String,
|
||||||
/// Current state of the issue.
|
/// Current state of the issue.
|
||||||
state: LWWReg<Max<State>>,
|
state: State,
|
||||||
/// Associated tags.
|
/// Associated tags.
|
||||||
tags: LWWSet<Tag>,
|
tags: BTreeSet<Tag>,
|
||||||
/// Discussion around this issue.
|
/// Discussion around this issue.
|
||||||
thread: Thread,
|
thread: Thread,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Semilattice for Issue {
|
|
||||||
fn merge(&mut self, other: Self) {
|
|
||||||
self.assignees.merge(other.assignees);
|
|
||||||
self.title.merge(other.title);
|
|
||||||
self.state.merge(other.state);
|
|
||||||
self.tags.merge(other.tags);
|
|
||||||
self.thread.merge(other.thread);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Issue {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
assignees: LWWSet::default(),
|
|
||||||
title: LWWReg::initial(Max::from(String::default())),
|
|
||||||
state: LWWReg::initial(Max::from(State::default())),
|
|
||||||
tags: LWWSet::default(),
|
|
||||||
thread: Thread::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl store::FromHistory for Issue {
|
impl store::FromHistory for Issue {
|
||||||
type Action = Action;
|
type Action = Action;
|
||||||
type Error = Error;
|
type Error = Error;
|
||||||
|
|
@ -137,7 +113,7 @@ impl store::FromHistory for Issue {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), Self::Error> {
|
fn validate(&self) -> Result<(), Self::Error> {
|
||||||
if self.title.get().is_empty() {
|
if self.title.is_empty() {
|
||||||
return Err(Error::Validate("title is empty"));
|
return Err(Error::Validate("title is empty"));
|
||||||
}
|
}
|
||||||
if self.thread.validate().is_err() {
|
if self.thread.validate().is_err() {
|
||||||
|
|
@ -146,45 +122,34 @@ impl store::FromHistory for Issue {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply<R: ReadRepository>(
|
fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
|
||||||
&mut self,
|
for action in op.actions {
|
||||||
ops: impl IntoIterator<Item = Op>,
|
match action {
|
||||||
repo: &R,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
for op in ops {
|
|
||||||
match op.action {
|
|
||||||
Action::Assign { add, remove } => {
|
Action::Assign { add, remove } => {
|
||||||
for assignee in add {
|
for assignee in add {
|
||||||
self.assignees.insert(assignee, op.clock);
|
self.assignees.insert(assignee);
|
||||||
}
|
}
|
||||||
for assignee in remove {
|
for assignee in remove {
|
||||||
self.assignees.remove(assignee, op.clock);
|
self.assignees.remove(&assignee);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Edit { title } => {
|
Action::Edit { title } => {
|
||||||
self.title.set(title, op.clock);
|
self.title = title;
|
||||||
}
|
}
|
||||||
Action::Lifecycle { state } => {
|
Action::Lifecycle { state } => {
|
||||||
self.state.set(state, op.clock);
|
self.state = state;
|
||||||
}
|
}
|
||||||
Action::Tag { add, remove } => {
|
Action::Tag { add, remove } => {
|
||||||
for tag in add {
|
for tag in add {
|
||||||
self.tags.insert(tag, op.clock);
|
self.tags.insert(tag);
|
||||||
}
|
}
|
||||||
for tag in remove {
|
for tag in remove {
|
||||||
self.tags.remove(tag, op.clock);
|
self.tags.remove(&tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Thread { action } => {
|
Action::Thread { action } => {
|
||||||
self.thread.apply(
|
self.thread.apply(
|
||||||
[cob::Op::new(
|
cob::Op::new(op.id, action, op.author, op.timestamp, op.identity),
|
||||||
op.id,
|
|
||||||
action,
|
|
||||||
op.author,
|
|
||||||
op.timestamp,
|
|
||||||
op.clock,
|
|
||||||
op.identity,
|
|
||||||
)],
|
|
||||||
repo,
|
repo,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
@ -200,11 +165,11 @@ impl Issue {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn title(&self) -> &str {
|
pub fn title(&self) -> &str {
|
||||||
self.title.get().as_str()
|
self.title.as_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn state(&self) -> &State {
|
pub fn state(&self) -> &State {
|
||||||
self.state.get()
|
&self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tags(&self) -> impl Iterator<Item = &Tag> {
|
pub fn tags(&self) -> impl Iterator<Item = &Tag> {
|
||||||
|
|
@ -333,7 +298,6 @@ impl store::Transaction<Issue> {
|
||||||
|
|
||||||
pub struct IssueMut<'a, 'g, R> {
|
pub struct IssueMut<'a, 'g, R> {
|
||||||
id: ObjectId,
|
id: ObjectId,
|
||||||
clock: clock::Lamport,
|
|
||||||
issue: Issue,
|
issue: Issue,
|
||||||
store: &'g mut Issues<'a, R>,
|
store: &'g mut Issues<'a, R>,
|
||||||
}
|
}
|
||||||
|
|
@ -342,16 +306,21 @@ impl<'a, 'g, R> IssueMut<'a, 'g, R>
|
||||||
where
|
where
|
||||||
R: WriteRepository + cob::Store,
|
R: WriteRepository + cob::Store,
|
||||||
{
|
{
|
||||||
|
/// Reload the issue data from storage.
|
||||||
|
pub fn reload(&mut self) -> Result<(), store::Error> {
|
||||||
|
self.issue = self
|
||||||
|
.store
|
||||||
|
.get(&self.id)?
|
||||||
|
.ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the issue id.
|
/// Get the issue id.
|
||||||
pub fn id(&self) -> &ObjectId {
|
pub fn id(&self) -> &ObjectId {
|
||||||
&self.id
|
&self.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the internal logical clock.
|
|
||||||
pub fn clock(&self) -> &clock::Lamport {
|
|
||||||
&self.clock
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assign one or more actors to an issue.
|
/// Assign one or more actors to an issue.
|
||||||
pub fn assign<G: Signer>(
|
pub fn assign<G: Signer>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -449,12 +418,11 @@ where
|
||||||
G: Signer,
|
G: Signer,
|
||||||
F: FnOnce(&mut Transaction<Issue>) -> Result<(), store::Error>,
|
F: FnOnce(&mut Transaction<Issue>) -> Result<(), store::Error>,
|
||||||
{
|
{
|
||||||
let mut tx = Transaction::new(*signer.public_key(), self.clock);
|
let mut tx = Transaction::new(*signer.public_key());
|
||||||
operations(&mut tx)?;
|
operations(&mut tx)?;
|
||||||
let (ops, clock, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
let (ops, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
|
||||||
|
|
||||||
self.issue.apply(ops, self.store.as_ref())?;
|
self.issue.apply(ops, self.store.as_ref())?;
|
||||||
self.clock = clock;
|
|
||||||
|
|
||||||
Ok(commit)
|
Ok(commit)
|
||||||
}
|
}
|
||||||
|
|
@ -501,19 +469,18 @@ where
|
||||||
|
|
||||||
/// Get an issue.
|
/// Get an issue.
|
||||||
pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, store::Error> {
|
pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, store::Error> {
|
||||||
self.raw.get(id).map(|r| r.map(|(i, _clock)| i))
|
self.raw.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get an issue mutably.
|
/// Get an issue mutably.
|
||||||
pub fn get_mut<'g>(&'g mut self, id: &ObjectId) -> Result<IssueMut<'a, 'g, R>, store::Error> {
|
pub fn get_mut<'g>(&'g mut self, id: &ObjectId) -> Result<IssueMut<'a, 'g, R>, store::Error> {
|
||||||
let (issue, clock) = self
|
let issue = self
|
||||||
.raw
|
.raw
|
||||||
.get(id)?
|
.get(id)?
|
||||||
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
|
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
|
||||||
|
|
||||||
Ok(IssueMut {
|
Ok(IssueMut {
|
||||||
id: *id,
|
id: *id,
|
||||||
clock,
|
|
||||||
issue,
|
issue,
|
||||||
store: self,
|
store: self,
|
||||||
})
|
})
|
||||||
|
|
@ -528,21 +495,17 @@ where
|
||||||
assignees: &[ActorId],
|
assignees: &[ActorId],
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<IssueMut<'a, 'g, R>, Error> {
|
) -> Result<IssueMut<'a, 'g, R>, Error> {
|
||||||
let (id, issue, clock) =
|
let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
|
||||||
Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
|
tx.thread(description)?;
|
||||||
tx.thread(description)?;
|
tx.assign(assignees.to_owned(), [])?;
|
||||||
tx.assign(assignees.to_owned(), [])?;
|
tx.edit(title)?;
|
||||||
tx.edit(title)?;
|
tx.tag(tags.to_owned(), [])?;
|
||||||
tx.tag(tags.to_owned(), [])?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
// Just a sanity check that our clock is advancing as expected.
|
|
||||||
debug_assert_eq!(clock.get(), 1);
|
|
||||||
|
|
||||||
Ok(IssueMut {
|
Ok(IssueMut {
|
||||||
id,
|
id,
|
||||||
clock,
|
|
||||||
issue,
|
issue,
|
||||||
store: self,
|
store: self,
|
||||||
})
|
})
|
||||||
|
|
@ -553,7 +516,7 @@ where
|
||||||
let all = self.all()?;
|
let all = self.all()?;
|
||||||
let state_groups =
|
let state_groups =
|
||||||
all.filter_map(|s| s.ok())
|
all.filter_map(|s| s.ok())
|
||||||
.fold(IssueCounts::default(), |mut state, (_, p, _)| {
|
.fold(IssueCounts::default(), |mut state, (_, p)| {
|
||||||
match p.state() {
|
match p.state() {
|
||||||
State::Open => state.open += 1,
|
State::Open => state.open += 1,
|
||||||
State::Closed { .. } => state.closed += 1,
|
State::Closed { .. } => state.closed += 1,
|
||||||
|
|
@ -610,6 +573,77 @@ mod test {
|
||||||
use crate::test;
|
use crate::test;
|
||||||
use crate::test::arbitrary;
|
use crate::test::arbitrary;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_concurrency() {
|
||||||
|
let t = test::setup::Network::default();
|
||||||
|
let mut issues_alice = Issues::open(&*t.alice.repo).unwrap();
|
||||||
|
let mut bob_issues = Issues::open(&*t.bob.repo).unwrap();
|
||||||
|
let mut eve_issues = Issues::open(&*t.eve.repo).unwrap();
|
||||||
|
|
||||||
|
let mut issue_alice = issues_alice
|
||||||
|
.create("Alice Issue", "Alice's comment", &[], &[], &t.alice.signer)
|
||||||
|
.unwrap();
|
||||||
|
let id = *issue_alice.id();
|
||||||
|
|
||||||
|
t.bob.repo.fetch(&t.alice);
|
||||||
|
t.eve.repo.fetch(&t.alice);
|
||||||
|
|
||||||
|
let mut issue_eve = eve_issues.get_mut(&id).unwrap();
|
||||||
|
let mut issue_bob = bob_issues.get_mut(&id).unwrap();
|
||||||
|
|
||||||
|
issue_bob
|
||||||
|
.comment("Bob's reply", id.into(), &t.bob.signer)
|
||||||
|
.unwrap();
|
||||||
|
issue_alice
|
||||||
|
.comment("Alice's reply", id.into(), &t.alice.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(issue_bob.comments().count(), 2);
|
||||||
|
assert_eq!(issue_alice.comments().count(), 2);
|
||||||
|
|
||||||
|
t.bob.repo.fetch(&t.alice);
|
||||||
|
issue_bob.reload().unwrap();
|
||||||
|
assert_eq!(issue_bob.comments().count(), 3);
|
||||||
|
|
||||||
|
t.alice.repo.fetch(&t.bob);
|
||||||
|
issue_alice.reload().unwrap();
|
||||||
|
assert_eq!(issue_alice.comments().count(), 3);
|
||||||
|
|
||||||
|
let bob_comments = issue_bob
|
||||||
|
.comments()
|
||||||
|
.map(|(_, c)| c.body())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let alice_comments = issue_alice
|
||||||
|
.comments()
|
||||||
|
.map(|(_, c)| c.body())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(bob_comments, alice_comments);
|
||||||
|
|
||||||
|
t.eve.repo.fetch(&t.alice);
|
||||||
|
|
||||||
|
let eve_reply = issue_eve
|
||||||
|
.comment("Eve's reply", id.into(), &t.eve.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
t.bob.repo.fetch(&t.eve);
|
||||||
|
t.alice.repo.fetch(&t.eve);
|
||||||
|
|
||||||
|
issue_alice.reload().unwrap();
|
||||||
|
issue_bob.reload().unwrap();
|
||||||
|
issue_eve.reload().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(issue_eve.comments().count(), 4);
|
||||||
|
assert_eq!(issue_bob.comments().count(), 4);
|
||||||
|
assert_eq!(issue_alice.comments().count(), 4);
|
||||||
|
|
||||||
|
let (first, _) = issue_bob.comments().next().unwrap();
|
||||||
|
let (last, _) = issue_bob.comments().last().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(*first, issue_alice.id.into());
|
||||||
|
assert_eq!(*last, eve_reply);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ordering() {
|
fn test_ordering() {
|
||||||
assert!(CloseReason::Solved > CloseReason::Other);
|
assert!(CloseReason::Solved > CloseReason::Other);
|
||||||
|
|
@ -623,11 +657,8 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_create_and_assign() {
|
fn test_issue_create_and_assign() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
|
|
||||||
let assignee: ActorId = arbitrary::gen(1);
|
let assignee: ActorId = arbitrary::gen(1);
|
||||||
let assignee_two: ActorId = arbitrary::gen(1);
|
let assignee_two: ActorId = arbitrary::gen(1);
|
||||||
|
|
@ -637,7 +668,7 @@ mod test {
|
||||||
"Blah blah blah.",
|
"Blah blah blah.",
|
||||||
&[],
|
&[],
|
||||||
&[assignee],
|
&[assignee],
|
||||||
&signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -649,7 +680,7 @@ mod test {
|
||||||
assert!(assignees.contains(&Did::from(assignee)));
|
assert!(assignees.contains(&Did::from(assignee)));
|
||||||
|
|
||||||
let mut issue = issues.get_mut(&id).unwrap();
|
let mut issue = issues.get_mut(&id).unwrap();
|
||||||
issue.assign([assignee_two], &signer).unwrap();
|
issue.assign([assignee_two], &node.signer).unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
@ -662,11 +693,8 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_create_and_reassign() {
|
fn test_issue_create_and_reassign() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
|
|
||||||
let assignee: ActorId = arbitrary::gen(1);
|
let assignee: ActorId = arbitrary::gen(1);
|
||||||
let assignee_two: ActorId = arbitrary::gen(1);
|
let assignee_two: ActorId = arbitrary::gen(1);
|
||||||
|
|
@ -676,12 +704,12 @@ mod test {
|
||||||
"Blah blah blah.",
|
"Blah blah blah.",
|
||||||
&[],
|
&[],
|
||||||
&[assignee],
|
&[assignee],
|
||||||
&signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue.assign([assignee_two], &signer).unwrap();
|
issue.assign([assignee_two], &node.signer).unwrap();
|
||||||
issue.assign([assignee_two], &signer).unwrap();
|
issue.assign([assignee_two], &node.signer).unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
@ -694,23 +722,18 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_create_and_get() {
|
fn test_issue_create_and_get() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let created = issues
|
let created = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(created.clock().get(), 1);
|
|
||||||
|
|
||||||
let (id, created) = (created.id, created.issue);
|
let (id, created) = (created.id, created.issue);
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
||||||
assert_eq!(created, issue);
|
assert_eq!(created, issue);
|
||||||
assert_eq!(issue.title(), "My first issue");
|
assert_eq!(issue.title(), "My first issue");
|
||||||
assert_eq!(issue.author().id, Did::from(signer.public_key()));
|
assert_eq!(issue.author().id, Did::from(node.signer.public_key()));
|
||||||
assert_eq!(issue.description().1, "Blah blah blah.");
|
assert_eq!(issue.description().1, "Blah blah blah.");
|
||||||
assert_eq!(issue.comments().count(), 1);
|
assert_eq!(issue.comments().count(), 1);
|
||||||
assert_eq!(issue.state(), &State::Open);
|
assert_eq!(issue.state(), &State::Open);
|
||||||
|
|
@ -718,13 +741,10 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_create_and_change_state() {
|
fn test_issue_create_and_change_state() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue
|
issue
|
||||||
|
|
@ -732,7 +752,7 @@ mod test {
|
||||||
State::Closed {
|
State::Closed {
|
||||||
reason: CloseReason::Other,
|
reason: CloseReason::Other,
|
||||||
},
|
},
|
||||||
&signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -746,7 +766,7 @@ mod test {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
issue.lifecycle(State::Open, &signer).unwrap();
|
issue.lifecycle(State::Open, &node.signer).unwrap();
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
||||||
assert_eq!(*issue.state(), State::Open);
|
assert_eq!(*issue.state(), State::Open);
|
||||||
|
|
@ -754,11 +774,8 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_create_and_unassign() {
|
fn test_issue_create_and_unassign() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
|
|
||||||
let assignee: ActorId = arbitrary::gen(1);
|
let assignee: ActorId = arbitrary::gen(1);
|
||||||
let assignee_two: ActorId = arbitrary::gen(1);
|
let assignee_two: ActorId = arbitrary::gen(1);
|
||||||
|
|
@ -768,11 +785,11 @@ mod test {
|
||||||
"Blah blah blah.",
|
"Blah blah blah.",
|
||||||
&[],
|
&[],
|
||||||
&[assignee, assignee_two],
|
&[assignee, assignee_two],
|
||||||
&signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue.unassign([assignee], &signer).unwrap();
|
issue.unassign([assignee], &node.signer).unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
@ -784,16 +801,13 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_edit() {
|
fn test_issue_edit() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue.edit("Sorry typo", &signer).unwrap();
|
issue.edit("Sorry typo", &node.signer).unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
@ -804,17 +818,14 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_edit_description() {
|
fn test_issue_edit_description() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue
|
issue
|
||||||
.edit_description("Bob Loblaw law blog", &signer)
|
.edit_description("Bob Loblaw law blog", &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
|
|
@ -826,19 +837,16 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_react() {
|
fn test_issue_react() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (comment, _) = issue.root();
|
let (comment, _) = issue.root();
|
||||||
let comment = *comment;
|
let comment = *comment;
|
||||||
let reaction = Reaction::new('🥳').unwrap();
|
let reaction = Reaction::new('🥳').unwrap();
|
||||||
issue.react(comment, reaction, &signer).unwrap();
|
issue.react(comment, reaction, &node.signer).unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
@ -851,19 +859,16 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_reply() {
|
fn test_issue_reply() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let (root, _) = issue.root();
|
let (root, _) = issue.root();
|
||||||
let root = *root;
|
let root = *root;
|
||||||
|
|
||||||
let c1 = issue.comment("Hi hi hi.", root, &signer).unwrap();
|
let c1 = issue.comment("Hi hi hi.", root, &node.signer).unwrap();
|
||||||
let c2 = issue.comment("Ha ha ha.", root, &signer).unwrap();
|
let c2 = issue.comment("Ha ha ha.", root, &node.signer).unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let mut issue = issues.get_mut(&id).unwrap();
|
let mut issue = issues.get_mut(&id).unwrap();
|
||||||
|
|
@ -873,10 +878,10 @@ mod test {
|
||||||
assert_eq!(reply1.body(), "Hi hi hi.");
|
assert_eq!(reply1.body(), "Hi hi hi.");
|
||||||
assert_eq!(reply2.body(), "Ha ha ha.");
|
assert_eq!(reply2.body(), "Ha ha ha.");
|
||||||
|
|
||||||
issue.comment("Re: Hi.", c1, &signer).unwrap();
|
issue.comment("Re: Hi.", c1, &node.signer).unwrap();
|
||||||
issue.comment("Re: Ha.", c2, &signer).unwrap();
|
issue.comment("Re: Ha.", c2, &node.signer).unwrap();
|
||||||
issue.comment("Re: Ha. Ha.", c2, &signer).unwrap();
|
issue.comment("Re: Ha. Ha.", c2, &node.signer).unwrap();
|
||||||
issue.comment("Re: Ha. Ha. Ha.", c2, &signer).unwrap();
|
issue.comment("Re: Ha. Ha. Ha.", c2, &node.signer).unwrap();
|
||||||
|
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
||||||
|
|
@ -891,11 +896,8 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_tag() {
|
fn test_issue_tag() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let bug_tag = Tag::new("bug").unwrap();
|
let bug_tag = Tag::new("bug").unwrap();
|
||||||
let ux_tag = Tag::new("ux").unwrap();
|
let ux_tag = Tag::new("ux").unwrap();
|
||||||
let wontfix_tag = Tag::new("wontfix").unwrap();
|
let wontfix_tag = Tag::new("wontfix").unwrap();
|
||||||
|
|
@ -905,12 +907,12 @@ mod test {
|
||||||
"Blah blah blah.",
|
"Blah blah blah.",
|
||||||
&[ux_tag.clone()],
|
&[ux_tag.clone()],
|
||||||
&[],
|
&[],
|
||||||
&signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
issue.tag([bug_tag.clone()], [], &signer).unwrap();
|
issue.tag([bug_tag.clone()], [], &node.signer).unwrap();
|
||||||
issue.tag([wontfix_tag.clone()], [], &signer).unwrap();
|
issue.tag([wontfix_tag.clone()], [], &node.signer).unwrap();
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
@ -923,26 +925,19 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_comment() {
|
fn test_issue_comment() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let author = *node.signer.public_key();
|
||||||
signer, project, ..
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let author = *signer.public_key();
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let mut issue = issues
|
let mut issue = issues
|
||||||
.create("My first issue", "Blah blah blah.", &[], &[], &signer)
|
.create("My first issue", "Blah blah blah.", &[], &[], &node.signer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(issue.clock.get(), 1);
|
|
||||||
|
|
||||||
// The root thread op id is always the same.
|
// The root thread op id is always the same.
|
||||||
let (c0, _) = issue.root();
|
let (c0, _) = issue.root();
|
||||||
let c0 = *c0;
|
let c0 = *c0;
|
||||||
|
|
||||||
issue.comment("Ho ho ho.", c0, &signer).unwrap();
|
issue.comment("Ho ho ho.", c0, &node.signer).unwrap();
|
||||||
issue.comment("Ha ha ha.", c0, &signer).unwrap();
|
issue.comment("Ha ha ha.", c0, &node.signer).unwrap();
|
||||||
|
|
||||||
assert_eq!(issue.clock.get(), 3);
|
|
||||||
|
|
||||||
let id = issue.id;
|
let id = issue.id;
|
||||||
let issue = issues.get(&id).unwrap().unwrap();
|
let issue = issues.get(&id).unwrap().unwrap();
|
||||||
|
|
@ -976,20 +971,23 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_all() {
|
fn test_issue_all() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
|
|
||||||
issues.create("First", "Blah", &[], &[], &signer).unwrap();
|
issues
|
||||||
issues.create("Second", "Blah", &[], &[], &signer).unwrap();
|
.create("First", "Blah", &[], &[], &node.signer)
|
||||||
issues.create("Third", "Blah", &[], &[], &signer).unwrap();
|
.unwrap();
|
||||||
|
issues
|
||||||
|
.create("Second", "Blah", &[], &[], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
issues
|
||||||
|
.create("Third", "Blah", &[], &[], &node.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let issues = issues
|
let issues = issues
|
||||||
.all()
|
.all()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.map(|r| r.map(|(_, i, _)| i))
|
.map(|r| r.map(|(_, i)| i))
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -1002,18 +1000,15 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_issue_multilines() {
|
fn test_issue_multilines() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
|
||||||
let test::setup::Context {
|
let mut issues = Issues::open(&*repo).unwrap();
|
||||||
signer, project, ..
|
|
||||||
} = test::setup::Context::new(&tmp);
|
|
||||||
let mut issues = Issues::open(&project).unwrap();
|
|
||||||
let created = issues
|
let created = issues
|
||||||
.create(
|
.create(
|
||||||
"My first issue",
|
"My first issue",
|
||||||
"Blah blah blah.\nYah yah yah",
|
"Blah blah blah.\nYah yah yah",
|
||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
&signer,
|
&node.signer,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -1022,7 +1017,7 @@ mod test {
|
||||||
|
|
||||||
assert_eq!(created, issue);
|
assert_eq!(created, issue);
|
||||||
assert_eq!(issue.title(), "My first issue");
|
assert_eq!(issue.title(), "My first issue");
|
||||||
assert_eq!(issue.author().id, Did::from(signer.public_key()));
|
assert_eq!(issue.author().id, Did::from(node.signer.public_key()));
|
||||||
assert_eq!(issue.description().1, "Blah blah blah.\nYah yah yah");
|
assert_eq!(issue.description().1, "Blah blah blah.\nYah yah yah");
|
||||||
assert_eq!(issue.comments().count(), 1);
|
assert_eq!(issue.comments().count(), 1);
|
||||||
assert_eq!(issue.state(), &State::Open);
|
assert_eq!(issue.state(), &State::Open);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ use nonempty::NonEmpty;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use radicle_cob::history::{Entry, EntryId};
|
use radicle_cob::history::{Entry, EntryId};
|
||||||
use radicle_crdt::clock;
|
|
||||||
use radicle_crdt::clock::Lamport;
|
|
||||||
use radicle_crypto::PublicKey;
|
use radicle_crypto::PublicKey;
|
||||||
|
|
||||||
|
use crate::cob::Timestamp;
|
||||||
use crate::git;
|
use crate::git;
|
||||||
|
|
||||||
/// The author of an [`Op`].
|
/// The author of an [`Op`].
|
||||||
|
|
@ -29,13 +28,11 @@ pub struct Op<A> {
|
||||||
/// Id of the entry under which this operation lives.
|
/// Id of the entry under which this operation lives.
|
||||||
pub id: EntryId,
|
pub id: EntryId,
|
||||||
/// The action carried out by this operation.
|
/// The action carried out by this operation.
|
||||||
pub action: A,
|
pub actions: NonEmpty<A>,
|
||||||
/// The author of the operation.
|
/// The author of the operation.
|
||||||
pub author: ActorId,
|
pub author: ActorId,
|
||||||
/// Lamport clock.
|
|
||||||
pub clock: Lamport,
|
|
||||||
/// Timestamp of this operation.
|
/// Timestamp of this operation.
|
||||||
pub timestamp: clock::Physical,
|
pub timestamp: Timestamp,
|
||||||
/// Head of identity document committed to by this operation.
|
/// Head of identity document committed to by this operation.
|
||||||
pub identity: git::Oid,
|
pub identity: git::Oid,
|
||||||
}
|
}
|
||||||
|
|
@ -55,17 +52,15 @@ impl<A: Eq> Ord for Op<A> {
|
||||||
impl<A> Op<A> {
|
impl<A> Op<A> {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: EntryId,
|
id: EntryId,
|
||||||
action: A,
|
actions: impl Into<NonEmpty<A>>,
|
||||||
author: ActorId,
|
author: ActorId,
|
||||||
timestamp: impl Into<clock::Physical>,
|
timestamp: impl Into<Timestamp>,
|
||||||
clock: Lamport,
|
|
||||||
identity: git::Oid,
|
identity: git::Oid,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
action,
|
actions: actions.into(),
|
||||||
author,
|
author,
|
||||||
clock,
|
|
||||||
timestamp: timestamp.into(),
|
timestamp: timestamp.into(),
|
||||||
identity,
|
identity,
|
||||||
}
|
}
|
||||||
|
|
@ -76,9 +71,7 @@ impl<A> Op<A> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Ops<A>(pub NonEmpty<Op<A>>);
|
impl<'a, A> TryFrom<&'a Entry> for Op<A>
|
||||||
|
|
||||||
impl<'a, A> TryFrom<&'a Entry> for Ops<A>
|
|
||||||
where
|
where
|
||||||
for<'de> A: serde::Deserialize<'de>,
|
for<'de> A: serde::Deserialize<'de>,
|
||||||
{
|
{
|
||||||
|
|
@ -87,34 +80,32 @@ where
|
||||||
fn try_from(entry: &'a Entry) -> Result<Self, Self::Error> {
|
fn try_from(entry: &'a Entry) -> Result<Self, Self::Error> {
|
||||||
let id = *entry.id();
|
let id = *entry.id();
|
||||||
let identity = entry.resource();
|
let identity = entry.resource();
|
||||||
let ops = entry
|
let actions: Vec<_> = entry
|
||||||
.contents()
|
.contents()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|blob| {
|
.map(|blob| serde_json::from_slice(blob.as_slice()))
|
||||||
let action = serde_json::from_slice(blob.as_slice())?;
|
|
||||||
let op = Op {
|
|
||||||
id,
|
|
||||||
action,
|
|
||||||
author: *entry.actor(),
|
|
||||||
clock: entry.clock().into(),
|
|
||||||
timestamp: entry.timestamp().into(),
|
|
||||||
identity,
|
|
||||||
};
|
|
||||||
Ok::<_, Self::Error>(op)
|
|
||||||
})
|
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
// SAFETY: Entry is guaranteed to have at least one operation.
|
// SAFETY: Entry is guaranteed to have at least one operation.
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
Ok(Self(NonEmpty::from_vec(ops).unwrap()))
|
let actions = NonEmpty::from_vec(actions).unwrap();
|
||||||
|
let op = Op {
|
||||||
|
id,
|
||||||
|
actions,
|
||||||
|
author: *entry.actor(),
|
||||||
|
timestamp: Timestamp::from_secs(entry.timestamp()),
|
||||||
|
identity,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(op)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A: 'static> IntoIterator for Ops<A> {
|
impl<A: 'static> IntoIterator for Op<A> {
|
||||||
type Item = Op<A>;
|
type Item = A;
|
||||||
type IntoIter = <NonEmpty<Op<A>> as IntoIterator>::IntoIter;
|
type IntoIter = <NonEmpty<A> as IntoIterator>::IntoIter;
|
||||||
|
|
||||||
fn into_iter(self) -> Self::IntoIter {
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
self.0.into_iter()
|
self.actions.into_iter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,10 +6,10 @@ use std::ops::ControlFlow;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
use radicle_crdt::Lamport;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cob::op::{Op, Ops};
|
use crate::cob::common::Timestamp;
|
||||||
|
use crate::cob::op::Op;
|
||||||
use crate::cob::{ActorId, Create, EntryId, History, ObjectId, TypeName, Update, Updated};
|
use crate::cob::{ActorId, Create, EntryId, History, ObjectId, TypeName, Update, Updated};
|
||||||
use crate::git;
|
use crate::git;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
@ -20,7 +20,7 @@ use crate::{cob, identity};
|
||||||
/// History type for standard radicle COBs.
|
/// History type for standard radicle COBs.
|
||||||
pub const HISTORY_TYPE: &str = "radicle";
|
pub const HISTORY_TYPE: &str = "radicle";
|
||||||
|
|
||||||
pub trait HistoryAction {
|
pub trait HistoryAction: std::fmt::Debug {
|
||||||
/// Parent objects this action depends on. For example, patch revisions
|
/// Parent objects this action depends on. For example, patch revisions
|
||||||
/// have the commit objects as their parent.
|
/// have the commit objects as their parent.
|
||||||
fn parents(&self) -> Vec<git::Oid> {
|
fn parents(&self) -> Vec<git::Oid> {
|
||||||
|
|
@ -42,7 +42,7 @@ pub trait FromHistory: Sized + Default + PartialEq {
|
||||||
/// Apply a list of operations to the state.
|
/// Apply a list of operations to the state.
|
||||||
fn apply<R: ReadRepository>(
|
fn apply<R: ReadRepository>(
|
||||||
&mut self,
|
&mut self,
|
||||||
ops: impl IntoIterator<Item = Op<Self::Action>>,
|
op: Op<Self::Action>,
|
||||||
repo: &R,
|
repo: &R,
|
||||||
) -> Result<(), Self::Error>;
|
) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
|
@ -50,14 +50,11 @@ pub trait FromHistory: Sized + Default + PartialEq {
|
||||||
fn validate(&self) -> Result<(), Self::Error>;
|
fn validate(&self) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
/// Create an object from a history.
|
/// Create an object from a history.
|
||||||
fn from_history<R: ReadRepository>(
|
fn from_history<R: ReadRepository>(history: &History, repo: &R) -> Result<Self, Self::Error> {
|
||||||
history: &History,
|
|
||||||
repo: &R,
|
|
||||||
) -> Result<(Self, Lamport), Self::Error> {
|
|
||||||
let obj = history.traverse(Self::default(), |mut acc, _, entry| {
|
let obj = history.traverse(Self::default(), |mut acc, _, entry| {
|
||||||
match Ops::try_from(entry) {
|
match Op::try_from(entry) {
|
||||||
Ok(Ops(ops)) => {
|
Ok(op) => {
|
||||||
if let Err(err) = acc.apply(ops, repo) {
|
if let Err(err) = acc.apply(op, repo) {
|
||||||
log::warn!("Error applying op to `{}` state: {err}", Self::type_name());
|
log::warn!("Error applying op to `{}` state: {err}", Self::type_name());
|
||||||
return ControlFlow::Break(acc);
|
return ControlFlow::Break(acc);
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +72,7 @@ pub trait FromHistory: Sized + Default + PartialEq {
|
||||||
|
|
||||||
obj.validate()?;
|
obj.validate()?;
|
||||||
|
|
||||||
Ok((obj, history.clock().into()))
|
Ok(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an object from individual operations.
|
/// Create an object from individual operations.
|
||||||
|
|
@ -85,8 +82,9 @@ pub trait FromHistory: Sized + Default + PartialEq {
|
||||||
repo: &R,
|
repo: &R,
|
||||||
) -> Result<Self, Self::Error> {
|
) -> Result<Self, Self::Error> {
|
||||||
let mut state = Self::default();
|
let mut state = Self::default();
|
||||||
state.apply(ops, repo)?;
|
for op in ops {
|
||||||
|
state.apply(op, repo)?;
|
||||||
|
}
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +195,7 @@ where
|
||||||
message: &str,
|
message: &str,
|
||||||
actions: impl Into<NonEmpty<T::Action>>,
|
actions: impl Into<NonEmpty<T::Action>>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<(ObjectId, T, Lamport), Error> {
|
) -> Result<(ObjectId, T), Error> {
|
||||||
let actions = actions.into();
|
let actions = actions.into();
|
||||||
let parents = actions.iter().flat_map(T::Action::parents).collect();
|
let parents = actions.iter().flat_map(T::Action::parents).collect();
|
||||||
let contents = actions.try_map(encoding::encode)?;
|
let contents = actions.try_map(encoding::encode)?;
|
||||||
|
|
@ -214,11 +212,11 @@ where
|
||||||
contents,
|
contents,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
let (object, clock) = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;
|
let object = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;
|
||||||
|
|
||||||
self.repo.sign_refs(signer).map_err(Error::SignRefs)?;
|
self.repo.sign_refs(signer).map_err(Error::SignRefs)?;
|
||||||
|
|
||||||
Ok((*cob.id(), object, clock))
|
Ok((*cob.id(), object))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove an object.
|
/// Remove an object.
|
||||||
|
|
@ -250,30 +248,28 @@ where
|
||||||
T::Action: Serialize,
|
T::Action: Serialize,
|
||||||
{
|
{
|
||||||
/// Get an object.
|
/// Get an object.
|
||||||
pub fn get(&self, id: &ObjectId) -> Result<Option<(T, Lamport)>, Error> {
|
pub fn get(&self, id: &ObjectId) -> Result<Option<T>, Error> {
|
||||||
let cob = cob::get(self.repo, T::type_name(), id)?;
|
let cob = cob::get(self.repo, T::type_name(), id)?;
|
||||||
|
|
||||||
if let Some(cob) = cob {
|
if let Some(cob) = cob {
|
||||||
if cob.manifest().history_type != HISTORY_TYPE {
|
if cob.manifest().history_type != HISTORY_TYPE {
|
||||||
return Err(Error::HistoryType(cob.manifest().history_type.clone()));
|
return Err(Error::HistoryType(cob.manifest().history_type.clone()));
|
||||||
}
|
}
|
||||||
let (obj, clock) = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;
|
let obj = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;
|
||||||
|
|
||||||
Ok(Some((obj, clock)))
|
Ok(Some(obj))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all objects.
|
/// Return all objects.
|
||||||
pub fn all(
|
pub fn all(&self) -> Result<impl Iterator<Item = Result<(ObjectId, T), Error>> + 'a, Error> {
|
||||||
&self,
|
|
||||||
) -> Result<impl Iterator<Item = Result<(ObjectId, T, Lamport), Error>> + 'a, Error> {
|
|
||||||
let raw = cob::list(self.repo, T::type_name())?;
|
let raw = cob::list(self.repo, T::type_name())?;
|
||||||
|
|
||||||
Ok(raw.into_iter().map(|o| {
|
Ok(raw.into_iter().map(|o| {
|
||||||
let (obj, clock) = T::from_history(o.history(), self.repo).map_err(Error::apply)?;
|
let obj = T::from_history(o.history(), self.repo).map_err(Error::apply)?;
|
||||||
Ok((*o.id(), obj, clock))
|
Ok((*o.id(), obj))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,16 +290,14 @@ where
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Transaction<T: FromHistory> {
|
pub struct Transaction<T: FromHistory> {
|
||||||
actor: ActorId,
|
actor: ActorId,
|
||||||
clock: Lamport,
|
|
||||||
actions: Vec<T::Action>,
|
actions: Vec<T::Action>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: FromHistory> Transaction<T> {
|
impl<T: FromHistory> Transaction<T> {
|
||||||
/// Create a new transaction.
|
/// Create a new transaction.
|
||||||
pub fn new(actor: ActorId, clock: Lamport) -> Self {
|
pub fn new(actor: ActorId) -> Self {
|
||||||
Self {
|
Self {
|
||||||
actor,
|
actor,
|
||||||
clock,
|
|
||||||
actions: Vec::new(),
|
actions: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +308,7 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
store: &mut Store<T, R>,
|
store: &mut Store<T, R>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
operations: F,
|
operations: F,
|
||||||
) -> Result<(ObjectId, T, Lamport), Error>
|
) -> Result<(ObjectId, T), Error>
|
||||||
where
|
where
|
||||||
G: Signer,
|
G: Signer,
|
||||||
F: FnOnce(&mut Self) -> Result<(), Error>,
|
F: FnOnce(&mut Self) -> Result<(), Error>,
|
||||||
|
|
@ -324,20 +318,15 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
let actor = *signer.public_key();
|
let actor = *signer.public_key();
|
||||||
let mut tx = Transaction {
|
let mut tx = Transaction {
|
||||||
actor,
|
actor,
|
||||||
// Nb. The clock is never zero.
|
|
||||||
clock: Lamport::initial().tick(),
|
|
||||||
actions: Vec::new(),
|
actions: Vec::new(),
|
||||||
};
|
};
|
||||||
operations(&mut tx)?;
|
operations(&mut tx)?;
|
||||||
|
|
||||||
let actions = NonEmpty::from_vec(tx.actions)
|
let actions = NonEmpty::from_vec(tx.actions)
|
||||||
.expect("Transaction::initial: transaction must contain at least one operation");
|
.expect("Transaction::initial: transaction must contain at least one operation");
|
||||||
let (id, cob, clock) = store.create(message, actions, signer)?;
|
let (id, cob) = store.create(message, actions, signer)?;
|
||||||
|
|
||||||
// The history clock should be in sync with the tx clock.
|
Ok((id, cob))
|
||||||
assert_eq!(clock, tx.clock);
|
|
||||||
|
|
||||||
Ok((id, cob, clock))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an operation to this transaction.
|
/// Add an operation to this transaction.
|
||||||
|
|
@ -351,12 +340,12 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
///
|
///
|
||||||
/// Returns a list of operations that can be applied onto an in-memory CRDT.
|
/// Returns a list of operations that can be applied onto an in-memory CRDT.
|
||||||
pub fn commit<R, G: Signer>(
|
pub fn commit<R, G: Signer>(
|
||||||
mut self,
|
self,
|
||||||
msg: &str,
|
msg: &str,
|
||||||
id: ObjectId,
|
id: ObjectId,
|
||||||
store: &mut Store<T, R>,
|
store: &mut Store<T, R>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> Result<(Vec<cob::Op<T::Action>>, Lamport, EntryId), Error>
|
) -> Result<(cob::Op<T::Action>, EntryId), Error>
|
||||||
where
|
where
|
||||||
R: ReadRepository + SignRepository + cob::Store,
|
R: ReadRepository + SignRepository + cob::Store,
|
||||||
T::Action: Serialize + Clone,
|
T::Action: Serialize + Clone,
|
||||||
|
|
@ -366,27 +355,17 @@ impl<T: FromHistory> Transaction<T> {
|
||||||
let Updated { head, object } = store.update(id, msg, actions.clone(), signer)?;
|
let Updated { head, object } = store.update(id, msg, actions.clone(), signer)?;
|
||||||
let id = EntryId::from(head);
|
let id = EntryId::from(head);
|
||||||
let author = self.actor;
|
let author = self.actor;
|
||||||
let timestamp = object.history().timestamp().into();
|
let timestamp = Timestamp::from_secs(object.history().timestamp());
|
||||||
let clock = self.clock.tick();
|
|
||||||
let identity = store.identity;
|
let identity = store.identity;
|
||||||
|
let op = cob::Op {
|
||||||
|
id,
|
||||||
|
actions,
|
||||||
|
author,
|
||||||
|
timestamp,
|
||||||
|
identity,
|
||||||
|
};
|
||||||
|
|
||||||
// The history clock should be in sync with the tx clock.
|
Ok((op, id))
|
||||||
assert_eq!(object.history().clock(), self.clock.get());
|
|
||||||
|
|
||||||
// Start the clock from where the transcation clock started.
|
|
||||||
let ops = actions
|
|
||||||
.into_iter()
|
|
||||||
.map(|action| cob::Op {
|
|
||||||
id,
|
|
||||||
action,
|
|
||||||
author,
|
|
||||||
clock,
|
|
||||||
timestamp,
|
|
||||||
identity,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok((ops, clock, id))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
use std::collections::BTreeSet;
|
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
||||||
use nonempty::NonEmpty;
|
use nonempty::NonEmpty;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cob::common::clock;
|
use crate::cob::op::Op;
|
||||||
use crate::cob::op::{Op, Ops};
|
|
||||||
use crate::cob::patch;
|
use crate::cob::patch;
|
||||||
use crate::cob::patch::Patch;
|
use crate::cob::patch::Patch;
|
||||||
use crate::cob::store::encoding;
|
use crate::cob::store::encoding;
|
||||||
use crate::cob::{EntryId, History};
|
use crate::cob::{EntryId, History, Timestamp};
|
||||||
use crate::crypto::Signer;
|
use crate::crypto::Signer;
|
||||||
use crate::git;
|
use crate::git;
|
||||||
use crate::git::ext::author::Author;
|
use crate::git::ext::author::Author;
|
||||||
|
|
@ -29,6 +27,7 @@ use super::thread;
|
||||||
pub struct HistoryBuilder<T> {
|
pub struct HistoryBuilder<T> {
|
||||||
history: History,
|
history: History,
|
||||||
resource: Oid,
|
resource: Oid,
|
||||||
|
time: Timestamp,
|
||||||
witness: PhantomData<T>,
|
witness: PhantomData<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,12 +54,11 @@ impl HistoryBuilder<thread::Thread> {
|
||||||
|
|
||||||
impl<T: FromHistory> HistoryBuilder<T>
|
impl<T: FromHistory> HistoryBuilder<T>
|
||||||
where
|
where
|
||||||
T::Action: Serialize + Eq + 'static,
|
T::Action: for<'de> Deserialize<'de> + Serialize + Eq + 'static,
|
||||||
{
|
{
|
||||||
pub fn new<G: Signer>(action: &T::Action, signer: &G) -> HistoryBuilder<T> {
|
pub fn new<G: Signer>(action: &T::Action, time: Timestamp, signer: &G) -> HistoryBuilder<T> {
|
||||||
let resource = arbitrary::oid();
|
let resource = arbitrary::oid();
|
||||||
let timestamp = clock::Physical::now().as_secs();
|
let (data, root) = encoded::<T, _>(action, time, [], signer);
|
||||||
let (data, root) = encoded::<T, _>(action, timestamp as i64, [], signer);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
history: History::new_from_root(
|
history: History::new_from_root(
|
||||||
|
|
@ -68,8 +66,9 @@ where
|
||||||
*signer.public_key(),
|
*signer.public_key(),
|
||||||
resource,
|
resource,
|
||||||
NonEmpty::new(data),
|
NonEmpty::new(data),
|
||||||
timestamp,
|
time.as_secs(),
|
||||||
),
|
),
|
||||||
|
time,
|
||||||
resource,
|
resource,
|
||||||
witness: PhantomData,
|
witness: PhantomData,
|
||||||
}
|
}
|
||||||
|
|
@ -84,42 +83,19 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commit<G: Signer>(&mut self, action: &T::Action, signer: &G) -> git::ext::Oid {
|
pub fn commit<G: Signer>(&mut self, action: &T::Action, signer: &G) -> git::ext::Oid {
|
||||||
let timestamp = clock::Physical::now().as_secs();
|
let timestamp = self.time;
|
||||||
let tips = self.tips();
|
let tips = self.tips();
|
||||||
let (data, oid) = encoded::<T, _>(action, timestamp as i64, tips, signer);
|
let (data, oid) = encoded::<T, _>(action, timestamp, tips, signer);
|
||||||
|
|
||||||
self.history.extend(
|
self.history.extend(
|
||||||
oid,
|
oid,
|
||||||
*signer.public_key(),
|
*signer.public_key(),
|
||||||
self.resource,
|
self.resource,
|
||||||
NonEmpty::new(data),
|
NonEmpty::new(data),
|
||||||
timestamp,
|
timestamp.as_secs(),
|
||||||
);
|
);
|
||||||
oid
|
oid
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a sorted list of operations by traversing the history in topological order.
|
|
||||||
/// In the case of partial orderings, a random order will be returned, using the provided RNG.
|
|
||||||
pub fn sorted(&self, rng: &mut fastrand::Rng) -> Vec<Op<T::Action>> {
|
|
||||||
self.history
|
|
||||||
.sorted(|a, b| if rng.bool() { a.cmp(b) } else { b.cmp(a) })
|
|
||||||
.flat_map(|entry| {
|
|
||||||
Ops::try_from(entry).expect("HistoryBuilder::sorted: operations must be valid")
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return `n` permutations of the topological ordering of operations.
|
|
||||||
/// *This function will never return if less than `n` permutations exist.*
|
|
||||||
pub fn permutations(&self, n: usize) -> impl IntoIterator<Item = Vec<Op<T::Action>>> {
|
|
||||||
let mut permutations = BTreeSet::new();
|
|
||||||
let mut rng = fastrand::Rng::new();
|
|
||||||
|
|
||||||
while permutations.len() < n {
|
|
||||||
permutations.insert(self.sorted(&mut rng));
|
|
||||||
}
|
|
||||||
permutations.into_iter()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A> Deref for HistoryBuilder<A> {
|
impl<A> Deref for HistoryBuilder<A> {
|
||||||
|
|
@ -131,17 +107,20 @@ impl<A> Deref for HistoryBuilder<A> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new test history.
|
/// Create a new test history.
|
||||||
pub fn history<T: FromHistory, G: Signer>(action: &T::Action, signer: &G) -> HistoryBuilder<T>
|
pub fn history<T: FromHistory, G: Signer>(
|
||||||
|
action: &T::Action,
|
||||||
|
time: Timestamp,
|
||||||
|
signer: &G,
|
||||||
|
) -> HistoryBuilder<T>
|
||||||
where
|
where
|
||||||
T::Action: Serialize + Eq + 'static,
|
T::Action: Serialize + Eq + 'static,
|
||||||
{
|
{
|
||||||
HistoryBuilder::new(action, signer)
|
HistoryBuilder::new(action, time, signer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An object that can be used to create and sign operations.
|
/// An object that can be used to create and sign operations.
|
||||||
pub struct Actor<G> {
|
pub struct Actor<G> {
|
||||||
pub signer: G,
|
pub signer: G,
|
||||||
pub clock: clock::Lamport,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<G: Default> Default for Actor<G> {
|
impl<G: Default> Default for Actor<G> {
|
||||||
|
|
@ -152,10 +131,7 @@ impl<G: Default> Default for Actor<G> {
|
||||||
|
|
||||||
impl<G> Actor<G> {
|
impl<G> Actor<G> {
|
||||||
pub fn new(signer: G) -> Self {
|
pub fn new(signer: G) -> Self {
|
||||||
Self {
|
Self { signer }
|
||||||
signer,
|
|
||||||
clock: clock::Lamport::default(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,8 +140,8 @@ impl<G: Signer> Actor<G> {
|
||||||
pub fn op_with<A: Clone + Serialize>(
|
pub fn op_with<A: Clone + Serialize>(
|
||||||
&mut self,
|
&mut self,
|
||||||
action: A,
|
action: A,
|
||||||
clock: clock::Lamport,
|
|
||||||
identity: Oid,
|
identity: Oid,
|
||||||
|
timestamp: Timestamp,
|
||||||
) -> Op<A> {
|
) -> Op<A> {
|
||||||
let data = encoding::encode(serde_json::json!({
|
let data = encoding::encode(serde_json::json!({
|
||||||
"action": action,
|
"action": action,
|
||||||
|
|
@ -175,13 +151,12 @@ impl<G: Signer> Actor<G> {
|
||||||
let oid = git::raw::Oid::hash_object(git::raw::ObjectType::Blob, &data).unwrap();
|
let oid = git::raw::Oid::hash_object(git::raw::ObjectType::Blob, &data).unwrap();
|
||||||
let id = oid.into();
|
let id = oid.into();
|
||||||
let author = *self.signer.public_key();
|
let author = *self.signer.public_key();
|
||||||
let timestamp = clock::Physical::now();
|
let actions = NonEmpty::new(action);
|
||||||
|
|
||||||
Op {
|
Op {
|
||||||
id,
|
id,
|
||||||
action,
|
actions,
|
||||||
author,
|
author,
|
||||||
clock,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
identity,
|
identity,
|
||||||
}
|
}
|
||||||
|
|
@ -189,10 +164,10 @@ impl<G: Signer> Actor<G> {
|
||||||
|
|
||||||
/// Create a new operation.
|
/// Create a new operation.
|
||||||
pub fn op<A: Clone + Serialize>(&mut self, action: A) -> Op<A> {
|
pub fn op<A: Clone + Serialize>(&mut self, action: A) -> Op<A> {
|
||||||
let clock = self.clock.tick();
|
|
||||||
let identity = arbitrary::oid();
|
let identity = arbitrary::oid();
|
||||||
|
let timestamp = Timestamp::now();
|
||||||
|
|
||||||
self.op_with(action, clock, identity)
|
self.op_with(action, identity, timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the actor's DID.
|
/// Get the actor's DID.
|
||||||
|
|
@ -234,7 +209,7 @@ impl<G: Signer> Actor<G> {
|
||||||
/// that feeds into the hash entropy, so that changing any input will change the resulting oid.
|
/// that feeds into the hash entropy, so that changing any input will change the resulting oid.
|
||||||
pub fn encoded<T: FromHistory, G: Signer>(
|
pub fn encoded<T: FromHistory, G: Signer>(
|
||||||
action: &T::Action,
|
action: &T::Action,
|
||||||
timestamp: i64,
|
timestamp: Timestamp,
|
||||||
parents: impl IntoIterator<Item = Oid>,
|
parents: impl IntoIterator<Item = Oid>,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> (Vec<u8>, git::ext::Oid) {
|
) -> (Vec<u8>, git::ext::Oid) {
|
||||||
|
|
@ -244,7 +219,7 @@ pub fn encoded<T: FromHistory, G: Signer>(
|
||||||
let author = Author {
|
let author = Author {
|
||||||
name: "radicle".to_owned(),
|
name: "radicle".to_owned(),
|
||||||
email: signer.public_key().to_human(),
|
email: signer.public_key().to_human(),
|
||||||
time: git_ext::author::Time::new(timestamp, 0),
|
time: git_ext::author::Time::new(timestamp.as_secs() as i64, 0),
|
||||||
};
|
};
|
||||||
let commit = Commit::new::<_, _, OwnedTrailer>(
|
let commit = Commit::new::<_, _, OwnedTrailer>(
|
||||||
oid,
|
oid,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use radicle_crdt as crdt;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
|
@ -11,9 +11,6 @@ use crate::cob::common::{Reaction, Timestamp};
|
||||||
use crate::cob::{ActorId, EntryId, Op};
|
use crate::cob::{ActorId, EntryId, Op};
|
||||||
use crate::prelude::ReadRepository;
|
use crate::prelude::ReadRepository;
|
||||||
|
|
||||||
use crdt::clock::Lamport;
|
|
||||||
use crdt::{GMap, GSet, LWWSet, Max, Redactable, Semilattice};
|
|
||||||
|
|
||||||
/// Type name of a thread, as well as the domain for all thread operations.
|
/// Type name of a thread, as well as the domain for all thread operations.
|
||||||
/// Note that threads are not usually used standalone. They are embeded into other COBs.
|
/// Note that threads are not usually used standalone. They are embeded into other COBs.
|
||||||
pub static TYPENAME: Lazy<cob::TypeName> =
|
pub static TYPENAME: Lazy<cob::TypeName> =
|
||||||
|
|
@ -60,9 +57,9 @@ pub struct Comment {
|
||||||
/// Comment author.
|
/// Comment author.
|
||||||
author: ActorId,
|
author: ActorId,
|
||||||
/// The comment body.
|
/// The comment body.
|
||||||
edits: GMap<Lamport, Max<Edit>>,
|
edits: Vec<Edit>,
|
||||||
/// Reactions to this comment.
|
/// Reactions to this comment.
|
||||||
reactions: LWWSet<(ActorId, Reaction)>,
|
reactions: BTreeSet<(ActorId, Reaction)>,
|
||||||
/// Comment this is a reply to.
|
/// Comment this is a reply to.
|
||||||
/// Should always be set, except for the root comment.
|
/// Should always be set, except for the root comment.
|
||||||
reply_to: Option<CommentId>,
|
reply_to: Option<CommentId>,
|
||||||
|
|
@ -80,8 +77,8 @@ impl Comment {
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
author,
|
author,
|
||||||
reactions: LWWSet::default(),
|
reactions: BTreeSet::default(),
|
||||||
edits: GMap::singleton(Lamport::initial(), Max::from(edit)),
|
edits: vec![edit],
|
||||||
reply_to,
|
reply_to,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +88,7 @@ impl Comment {
|
||||||
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
|
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
|
||||||
// constructor.
|
// constructor.
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
self.edits.values().last().unwrap().get().body.as_str()
|
self.edits.last().unwrap().body.as_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the comment timestamp, which is the time of the *original* edit. To get the timestamp
|
/// Get the comment timestamp, which is the time of the *original* edit. To get the timestamp
|
||||||
|
|
@ -100,12 +97,7 @@ impl Comment {
|
||||||
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
|
// SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
|
||||||
// constructor.
|
// constructor.
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
self.edits
|
self.edits.first().unwrap().timestamp
|
||||||
.first_key_value()
|
|
||||||
.map(|(_, v)| v)
|
|
||||||
.unwrap()
|
|
||||||
.get()
|
|
||||||
.timestamp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the comment author.
|
/// Return the comment author.
|
||||||
|
|
@ -120,12 +112,12 @@ impl Comment {
|
||||||
|
|
||||||
/// Return the ordered list of edits for this comment, including the original version.
|
/// Return the ordered list of edits for this comment, including the original version.
|
||||||
pub fn edits(&self) -> impl Iterator<Item = &Edit> {
|
pub fn edits(&self) -> impl Iterator<Item = &Edit> {
|
||||||
self.edits.values().map(Max::get)
|
self.edits.iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an edit.
|
/// Add an edit.
|
||||||
pub fn edit(&mut self, clock: Lamport, body: String, timestamp: Timestamp) {
|
pub fn edit(&mut self, body: String, timestamp: Timestamp) {
|
||||||
self.edits.insert(clock, Edit { body, timestamp }.into())
|
self.edits.push(Edit { body, timestamp });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comment reactions.
|
/// Comment reactions.
|
||||||
|
|
@ -182,23 +174,16 @@ impl From<Action> for nonempty::NonEmpty<Action> {
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
pub struct Thread {
|
pub struct Thread {
|
||||||
/// The comments under the thread.
|
/// The comments under the thread.
|
||||||
comments: GMap<CommentId, Redactable<Comment>>,
|
comments: BTreeMap<CommentId, Option<Comment>>,
|
||||||
/// Comment timeline.
|
/// Comment timeline.
|
||||||
timeline: GSet<(Lamport, CommentId)>,
|
timeline: Vec<CommentId>,
|
||||||
}
|
|
||||||
|
|
||||||
impl Semilattice for Thread {
|
|
||||||
fn merge(&mut self, other: Self) {
|
|
||||||
self.comments.merge(other.comments);
|
|
||||||
self.timeline.merge(other.timeline);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Thread {
|
impl Thread {
|
||||||
pub fn new(id: CommentId, comment: Comment) -> Self {
|
pub fn new(id: CommentId, comment: Comment) -> Self {
|
||||||
Self {
|
Self {
|
||||||
comments: GMap::singleton(id, Redactable::Present(comment)),
|
comments: BTreeMap::from_iter([(id, Some(comment))]),
|
||||||
timeline: GSet::default(),
|
timeline: Vec::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,11 +200,7 @@ impl Thread {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn comment(&self, id: &CommentId) -> Option<&Comment> {
|
pub fn comment(&self, id: &CommentId) -> Option<&Comment> {
|
||||||
if let Some(Redactable::Present(comment)) = self.comments.get(id) {
|
self.comments.get(id).and_then(|o| o.as_ref())
|
||||||
Some(comment)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn root(&self) -> (&CommentId, &Comment) {
|
pub fn root(&self) -> (&CommentId, &Comment) {
|
||||||
|
|
@ -249,10 +230,10 @@ impl Thread {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&CommentId, &Comment)> + '_ {
|
pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&CommentId, &Comment)> + '_ {
|
||||||
self.timeline.iter().filter_map(|(_, id)| {
|
self.timeline.iter().filter_map(|id| {
|
||||||
self.comments
|
self.comments
|
||||||
.get(id)
|
.get(id)
|
||||||
.and_then(Redactable::get)
|
.and_then(|o| o.as_ref())
|
||||||
.map(|comment| (id, comment))
|
.map(|comment| (id, comment))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -273,51 +254,46 @@ impl cob::store::FromHistory for Thread {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply<R: ReadRepository>(
|
fn apply<R: ReadRepository>(&mut self, op: Op<Action>, _repo: &R) -> Result<(), Error> {
|
||||||
&mut self,
|
let id = op.id;
|
||||||
ops: impl IntoIterator<Item = Op<Action>>,
|
let author = op.author;
|
||||||
_repo: &R,
|
let timestamp = op.timestamp;
|
||||||
) -> Result<(), Error> {
|
|
||||||
for op in ops.into_iter() {
|
|
||||||
let id = op.id;
|
|
||||||
let author = op.author;
|
|
||||||
let timestamp = op.timestamp;
|
|
||||||
|
|
||||||
self.timeline.insert((op.clock, op.id));
|
debug_assert!(!self.timeline.contains(&op.id));
|
||||||
|
|
||||||
match op.action {
|
self.timeline.push(op.id);
|
||||||
|
|
||||||
|
for action in op.into_iter() {
|
||||||
|
match action {
|
||||||
Action::Comment { body, reply_to } => {
|
Action::Comment { body, reply_to } => {
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
return Err(Error::Comment(op.id));
|
return Err(Error::Comment(id));
|
||||||
}
|
}
|
||||||
// Nb. If a comment is already present, it must be redacted, because the
|
// Nb. If a comment is already present, it must be redacted, because the
|
||||||
// underlying store guarantees exactly-once delivery of ops.
|
// underlying store guarantees exactly-once delivery of ops.
|
||||||
self.comments.insert(
|
self.comments
|
||||||
id,
|
.insert(id, Some(Comment::new(author, body, reply_to, timestamp)));
|
||||||
Redactable::Present(Comment::new(author, body, reply_to, timestamp)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Action::Edit { id, body } => {
|
Action::Edit { id, body } => {
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
return Err(Error::Edit(op.id));
|
return Err(Error::Edit(id));
|
||||||
}
|
}
|
||||||
// It's possible for a comment to be redacted before we're able to edit it, in
|
// It's possible for a comment to be redacted before we're able to edit it, in
|
||||||
// case of a concurrent update.
|
// case of a concurrent update.
|
||||||
//
|
//
|
||||||
// However, it's *not* possible for the comment to be absent. Therefore we treat
|
// However, it's *not* possible for the comment to be absent. Therefore we treat
|
||||||
// that as an error.
|
// that as an error.
|
||||||
if let Some(redactable) = self.comments.get_mut(&id) {
|
if let Some(comment) = self.comments.get_mut(&id) {
|
||||||
if let Redactable::Present(comment) = redactable {
|
if let Some(comment) = comment {
|
||||||
comment.edit(op.clock, body, timestamp);
|
comment.edit(body, timestamp);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::Missing(id));
|
return Err(Error::Missing(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Redact { id } => {
|
Action::Redact { id } => {
|
||||||
// Redactions must have observed a comment to be valid.
|
|
||||||
if let Some(comment) = self.comments.get_mut(&id) {
|
if let Some(comment) = self.comments.get_mut(&id) {
|
||||||
comment.merge(Redactable::Redacted);
|
*comment = None;
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::Missing(id));
|
return Err(Error::Missing(id));
|
||||||
}
|
}
|
||||||
|
|
@ -327,13 +303,13 @@ impl cob::store::FromHistory for Thread {
|
||||||
reaction,
|
reaction,
|
||||||
active,
|
active,
|
||||||
} => {
|
} => {
|
||||||
let key = (op.author, reaction);
|
let key = (author, reaction);
|
||||||
if let Some(redactable) = self.comments.get_mut(&to) {
|
if let Some(comment) = self.comments.get_mut(&to) {
|
||||||
if let Redactable::Present(comment) = redactable {
|
if let Some(comment) = comment {
|
||||||
if active {
|
if active {
|
||||||
comment.reactions.insert(key, op.clock);
|
comment.reactions.insert(key);
|
||||||
} else {
|
} else {
|
||||||
comment.reactions.remove(key, op.clock);
|
comment.reactions.remove(&key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -348,14 +324,10 @@ impl cob::store::FromHistory for Thread {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeSet;
|
|
||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::{array, iter};
|
|
||||||
|
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use qcheck::{Arbitrary, TestResult};
|
use qcheck_macros::quickcheck;
|
||||||
|
|
||||||
use crdt::test::{assert_laws, WeightedGenerator};
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate as radicle;
|
use crate as radicle;
|
||||||
|
|
@ -407,15 +379,6 @@ mod tests {
|
||||||
body: body.to_owned(),
|
body: body.to_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// React to a comment.
|
|
||||||
pub fn react(&mut self, to: CommentId, reaction: Reaction, active: bool) -> Op<Action> {
|
|
||||||
self.op(Action::React {
|
|
||||||
to,
|
|
||||||
reaction,
|
|
||||||
active,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<G> Deref for Actor<G> {
|
impl<G> Deref for Actor<G> {
|
||||||
|
|
@ -432,111 +395,22 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Changes<const N: usize> {
|
|
||||||
permutations: [Vec<Op<Action>>; N],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<const N: usize> std::fmt::Debug for Changes<N> {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
for (i, p) in self.permutations.iter().enumerate() {
|
|
||||||
writeln!(
|
|
||||||
f,
|
|
||||||
"{i}: {:#?}",
|
|
||||||
p.iter().map(|c| &c.action).collect::<Vec<_>>()
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<const N: usize> Arbitrary for Changes<N> {
|
|
||||||
fn arbitrary(g: &mut qcheck::Gen) -> Self {
|
|
||||||
let rng = fastrand::Rng::with_seed(u64::arbitrary(g));
|
|
||||||
let gen = WeightedGenerator::<
|
|
||||||
(Lamport, Op<Action>),
|
|
||||||
(Actor<MockSigner>, Lamport, BTreeSet<EntryId>),
|
|
||||||
>::new(rng.clone())
|
|
||||||
.variant(3, |(actor, clock, comments), rng| {
|
|
||||||
let comment = actor.comment(
|
|
||||||
iter::repeat_with(|| rng.alphabetic())
|
|
||||||
.take(4)
|
|
||||||
.collect::<String>()
|
|
||||||
.as_str(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
comments.insert(comment.id);
|
|
||||||
|
|
||||||
Some((clock.tick(), comment))
|
|
||||||
})
|
|
||||||
.variant(2, |(actor, clock, comments), rng| {
|
|
||||||
if comments.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let id = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
|
||||||
let edit = actor.edit(
|
|
||||||
id,
|
|
||||||
iter::repeat_with(|| rng.alphabetic())
|
|
||||||
.take(4)
|
|
||||||
.collect::<String>()
|
|
||||||
.as_str(),
|
|
||||||
);
|
|
||||||
Some((clock.tick(), edit))
|
|
||||||
})
|
|
||||||
.variant(2, |(actor, clock, comments), rng| {
|
|
||||||
if comments.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let to = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
|
||||||
let react = actor.react(to, Reaction::new('✨').unwrap(), rng.bool());
|
|
||||||
|
|
||||||
Some((clock.tick(), react))
|
|
||||||
})
|
|
||||||
.variant(2, |(actor, clock, comments), rng| {
|
|
||||||
if comments.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let id = *comments.iter().nth(rng.usize(..comments.len())).unwrap();
|
|
||||||
comments.remove(&id);
|
|
||||||
let redact = actor.redact(id);
|
|
||||||
|
|
||||||
Some((clock.tick(), redact))
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut ops = vec![Actor::<MockSigner>::default().comment("Root", None)];
|
|
||||||
let mut permutations: [Vec<Op<Action>>; N] = array::from_fn(|_| Vec::new());
|
|
||||||
|
|
||||||
for (_, op) in gen.take(g.size()) {
|
|
||||||
ops.push(op);
|
|
||||||
}
|
|
||||||
|
|
||||||
for p in &mut permutations {
|
|
||||||
*p = ops.clone();
|
|
||||||
rng.shuffle(&mut ops);
|
|
||||||
}
|
|
||||||
|
|
||||||
Changes { permutations }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_redact_comment() {
|
fn test_redact_comment() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let radicle::test::setup::Node { signer, .. } = radicle::test::setup::Node::default();
|
||||||
let radicle::test::setup::Context { signer, .. } = radicle::test::setup::Context::new(&tmp);
|
|
||||||
let repo = gen::<MockRepository>(1);
|
let repo = gen::<MockRepository>(1);
|
||||||
let mut alice = Actor::new(signer);
|
let mut alice = Actor::new(signer);
|
||||||
let mut thread = Thread::default();
|
|
||||||
|
|
||||||
let a0 = alice.comment("First comment", None);
|
let a0 = alice.comment("First comment", None);
|
||||||
let a1 = alice.comment("Second comment", Some(a0.id()));
|
let a1 = alice.comment("Second comment", Some(a0.id()));
|
||||||
let a2 = alice.comment("Third comment", Some(a0.id()));
|
let a2 = alice.comment("Third comment", Some(a0.id()));
|
||||||
|
|
||||||
thread.apply([a0, a1.clone(), a2], &repo).unwrap();
|
let mut thread = Thread::from_ops([a0, a1.clone(), a2], &repo).unwrap();
|
||||||
assert_eq!(thread.comments().count(), 3);
|
assert_eq!(thread.comments().count(), 3);
|
||||||
|
|
||||||
// Redact the second comment.
|
// Redact the second comment.
|
||||||
let a3 = alice.redact(a1.id());
|
let a3 = alice.redact(a1.id());
|
||||||
thread.apply([a3], &repo).unwrap();
|
thread.apply(a3, &repo).unwrap();
|
||||||
|
|
||||||
let (_, comment0) = thread.comments().nth(0).unwrap();
|
let (_, comment0) = thread.comments().nth(0).unwrap();
|
||||||
let (_, comment1) = thread.comments().nth(1).unwrap();
|
let (_, comment1) = thread.comments().nth(1).unwrap();
|
||||||
|
|
@ -555,9 +429,7 @@ mod tests {
|
||||||
let c1 = alice.edit(c0.id(), "Goodbye world.");
|
let c1 = alice.edit(c0.id(), "Goodbye world.");
|
||||||
let c2 = alice.edit(c0.id(), "Goodbye world!");
|
let c2 = alice.edit(c0.id(), "Goodbye world!");
|
||||||
|
|
||||||
let mut t1 = Thread::default();
|
let t1 = Thread::from_ops([c0.clone(), c1, c2], &repo).unwrap();
|
||||||
t1.apply([c0.clone(), c1.clone(), c2.clone()], &repo)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let comment = t1.comment(&c0.id());
|
let comment = t1.comment(&c0.id());
|
||||||
let edits = comment.unwrap().edits().collect::<Vec<_>>();
|
let edits = comment.unwrap().edits().collect::<Vec<_>>();
|
||||||
|
|
@ -566,11 +438,6 @@ mod tests {
|
||||||
assert_eq!(edits[1].body.as_str(), "Goodbye world.");
|
assert_eq!(edits[1].body.as_str(), "Goodbye world.");
|
||||||
assert_eq!(edits[2].body.as_str(), "Goodbye world!");
|
assert_eq!(edits[2].body.as_str(), "Goodbye world!");
|
||||||
assert_eq!(t1.comment(&c0.id()).unwrap().body(), "Goodbye world!");
|
assert_eq!(t1.comment(&c0.id()).unwrap().body(), "Goodbye world!");
|
||||||
|
|
||||||
let mut t2 = Thread::default();
|
|
||||||
t2.apply([c0, c2, c1], &repo).unwrap(); // Apply in different order.
|
|
||||||
|
|
||||||
assert_eq!(t1, t2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -579,12 +446,14 @@ mod tests {
|
||||||
let bob = MockSigner::default();
|
let bob = MockSigner::default();
|
||||||
let eve = MockSigner::default();
|
let eve = MockSigner::default();
|
||||||
let repo = gen::<MockRepository>(1);
|
let repo = gen::<MockRepository>(1);
|
||||||
|
let time = Timestamp::now();
|
||||||
|
|
||||||
let mut a = test::history::<Thread, _>(
|
let mut a = test::history::<Thread, _>(
|
||||||
&Action::Comment {
|
&Action::Comment {
|
||||||
body: "Thread root".to_owned(),
|
body: "Thread root".to_owned(),
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
},
|
},
|
||||||
|
time,
|
||||||
&alice,
|
&alice,
|
||||||
);
|
);
|
||||||
a.comment("Alice comment", Some(a.root()), &alice);
|
a.comment("Alice comment", Some(a.root()), &alice);
|
||||||
|
|
@ -613,9 +482,9 @@ mod tests {
|
||||||
assert_eq!(a, b);
|
assert_eq!(a, b);
|
||||||
assert_eq!(b, e);
|
assert_eq!(b, e);
|
||||||
|
|
||||||
let (t1, _) = Thread::from_history(&a, &repo).unwrap();
|
let t1 = Thread::from_history(&a, &repo).unwrap();
|
||||||
let (t2, _) = Thread::from_history(&b, &repo).unwrap();
|
let t2 = Thread::from_history(&b, &repo).unwrap();
|
||||||
let (t3, _) = Thread::from_history(&e, &repo).unwrap();
|
let t3 = Thread::from_history(&e, &repo).unwrap();
|
||||||
|
|
||||||
assert_eq!(t1, t2);
|
assert_eq!(t1, t2);
|
||||||
assert_eq!(t2, t3);
|
assert_eq!(t2, t3);
|
||||||
|
|
@ -637,11 +506,6 @@ mod tests {
|
||||||
vec!["Thread root", "Alice comment", "Eve comment", "Bob comment"]
|
vec!["Thread root", "Alice comment", "Eve comment", "Bob comment"]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
for ops in a.permutations(2) {
|
|
||||||
let t = Thread::from_ops(ops, &repo).unwrap();
|
|
||||||
assert_eq!(t, t1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -649,12 +513,14 @@ mod tests {
|
||||||
let repo = gen::<MockRepository>(1);
|
let repo = gen::<MockRepository>(1);
|
||||||
let alice = MockSigner::default();
|
let alice = MockSigner::default();
|
||||||
let bob = MockSigner::default();
|
let bob = MockSigner::default();
|
||||||
|
let time = Timestamp::now();
|
||||||
|
|
||||||
let mut a = test::history::<Thread, _>(
|
let mut a = test::history::<Thread, _>(
|
||||||
&Action::Comment {
|
&Action::Comment {
|
||||||
body: "Thread root".to_owned(),
|
body: "Thread root".to_owned(),
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
},
|
},
|
||||||
|
time,
|
||||||
&alice,
|
&alice,
|
||||||
);
|
);
|
||||||
let mut b = a.clone();
|
let mut b = a.clone();
|
||||||
|
|
@ -664,7 +530,7 @@ mod tests {
|
||||||
|
|
||||||
a.merge(b);
|
a.merge(b);
|
||||||
|
|
||||||
let (thread, _) = Thread::from_history(&a, &repo).unwrap();
|
let thread = Thread::from_history(&a, &repo).unwrap();
|
||||||
|
|
||||||
assert_eq!(thread.comments().count(), 3);
|
assert_eq!(thread.comments().count(), 3);
|
||||||
|
|
||||||
|
|
@ -675,6 +541,65 @@ mod tests {
|
||||||
assert_eq!(first.edits, second.edits); // despite the content being the same.
|
assert_eq!(first.edits, second.edits); // despite the content being the same.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[quickcheck]
|
||||||
|
fn prop_ordering(timestamp: u64) {
|
||||||
|
let repo = gen::<MockRepository>(1);
|
||||||
|
let alice = MockSigner::default();
|
||||||
|
let bob = MockSigner::default();
|
||||||
|
let timestamp = Timestamp::from_secs(timestamp);
|
||||||
|
|
||||||
|
let h0 = test::history::<Thread, _>(
|
||||||
|
&Action::Comment {
|
||||||
|
body: "Thread root".to_owned(),
|
||||||
|
reply_to: None,
|
||||||
|
},
|
||||||
|
timestamp,
|
||||||
|
&alice,
|
||||||
|
);
|
||||||
|
let mut h1 = h0.clone();
|
||||||
|
let mut h2 = h0.clone();
|
||||||
|
|
||||||
|
let e1 = h1.commit(
|
||||||
|
&Action::Edit {
|
||||||
|
id: h0.root(),
|
||||||
|
body: String::from("Bye World."),
|
||||||
|
},
|
||||||
|
&alice,
|
||||||
|
);
|
||||||
|
let e2 = h2.commit(
|
||||||
|
&Action::Edit {
|
||||||
|
id: h0.root(),
|
||||||
|
body: String::from("Hi World."),
|
||||||
|
},
|
||||||
|
&bob,
|
||||||
|
);
|
||||||
|
|
||||||
|
h1.merge(h2);
|
||||||
|
|
||||||
|
let thread = Thread::from_history(&h1, &repo).unwrap();
|
||||||
|
let (_, comment) = thread.comments().next().unwrap();
|
||||||
|
|
||||||
|
// E1 and E2 are concurrent, so the final edit will depend on which is the greater hash.
|
||||||
|
if e2 > e1 {
|
||||||
|
assert_eq!(comment.body(), "Hi World.");
|
||||||
|
} else {
|
||||||
|
assert_eq!(comment.body(), "Bye World.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _e3 = h1.commit(
|
||||||
|
&Action::Edit {
|
||||||
|
id: h0.root(),
|
||||||
|
body: String::from("Hoho World!"),
|
||||||
|
},
|
||||||
|
&alice,
|
||||||
|
);
|
||||||
|
let thread = Thread::from_history(&h1, &repo).unwrap();
|
||||||
|
let (_, comment) = thread.comments().next().unwrap();
|
||||||
|
|
||||||
|
// E3 is causally dependent on E1 and E2, so it always wins.
|
||||||
|
assert_eq!(comment.body(), "Hoho World!");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_comment_redact_missing() {
|
fn test_comment_redact_missing() {
|
||||||
let repo = gen::<MockRepository>(1);
|
let repo = gen::<MockRepository>(1);
|
||||||
|
|
@ -682,7 +607,7 @@ mod tests {
|
||||||
let mut t = Thread::default();
|
let mut t = Thread::default();
|
||||||
let id = arbitrary::entry_id();
|
let id = arbitrary::entry_id();
|
||||||
|
|
||||||
t.apply([alice.redact(id)], &repo).unwrap_err();
|
t.apply(alice.redact(id), &repo).unwrap_err();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -692,54 +617,19 @@ mod tests {
|
||||||
let mut t = Thread::default();
|
let mut t = Thread::default();
|
||||||
let id = arbitrary::entry_id();
|
let id = arbitrary::entry_id();
|
||||||
|
|
||||||
t.apply([alice.edit(id, "Edited")], &repo).unwrap_err();
|
t.apply(alice.edit(id, "Edited"), &repo).unwrap_err();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_comment_edit_redacted() {
|
fn test_comment_edit_redacted() {
|
||||||
let repo = gen::<MockRepository>(1);
|
let repo = gen::<MockRepository>(1);
|
||||||
let mut alice = Actor::<MockSigner>::default();
|
let mut alice = Actor::<MockSigner>::default();
|
||||||
let mut t = Thread::default();
|
|
||||||
|
|
||||||
let a1 = alice.comment("Hi", None);
|
let a1 = alice.comment("Hi", None);
|
||||||
let a2 = alice.redact(a1.id);
|
let a2 = alice.redact(a1.id);
|
||||||
let a3 = alice.edit(a1.id, "Edited");
|
let a3 = alice.edit(a1.id, "Edited");
|
||||||
|
|
||||||
t.apply([a1, a2, a3], &repo).unwrap();
|
let t = Thread::from_ops([a1, a2, a3], &repo).unwrap();
|
||||||
assert_eq!(t.comments().count(), 0);
|
assert_eq!(t.comments().count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prop_invariants() {
|
|
||||||
fn property(repo: MockRepository, log: Changes<3>) -> TestResult {
|
|
||||||
let t = Thread::default();
|
|
||||||
let [p1, p2, p3] = log.permutations;
|
|
||||||
|
|
||||||
let mut t1 = t.clone();
|
|
||||||
if t1.apply(p1, &repo).is_err() {
|
|
||||||
return TestResult::discard();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut t2 = t.clone();
|
|
||||||
if t2.apply(p2, &repo).is_err() {
|
|
||||||
return TestResult::discard();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut t3 = t;
|
|
||||||
if t3.apply(p3, &repo).is_err() {
|
|
||||||
return TestResult::discard();
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(t1, t2);
|
|
||||||
assert_eq!(t2, t3);
|
|
||||||
assert_laws(&t1, &t2, &t3);
|
|
||||||
|
|
||||||
TestResult::passed()
|
|
||||||
}
|
|
||||||
qcheck::QuickCheck::new()
|
|
||||||
.min_tests_passed(100)
|
|
||||||
.max_tests(10000)
|
|
||||||
.gen(qcheck::Gen::new(7))
|
|
||||||
.quickcheck(property as fn(MockRepository, Changes<3>) -> TestResult);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,19 @@ impl git2::transport::SmartSubtransport for MockTransport {
|
||||||
url.node
|
url.node
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
assert!(
|
||||||
|
storage.exists(),
|
||||||
|
"The storage path {} must exist",
|
||||||
|
storage.display()
|
||||||
|
);
|
||||||
|
|
||||||
let git_dir = storage.join(url.repo.canonical());
|
let git_dir = storage.join(url.repo.canonical());
|
||||||
|
assert!(
|
||||||
|
git_dir.exists(),
|
||||||
|
"The repository {} must exist",
|
||||||
|
git_dir.display()
|
||||||
|
);
|
||||||
|
|
||||||
let mut cmd = process::Command::new("git");
|
let mut cmd = process::Command::new("git");
|
||||||
let mut child = cmd
|
let mut child = cmd
|
||||||
.arg("upload-pack")
|
.arg("upload-pack")
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,72 @@ pub mod assert;
|
||||||
pub mod fixtures;
|
pub mod fixtures;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|
||||||
pub mod setup {
|
use super::storage::{Namespaces, RefUpdate};
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
|
use crate::prelude::NodeId;
|
||||||
|
use crate::storage::WriteRepository;
|
||||||
|
|
||||||
|
/// Perform a fetch between two local repositories.
|
||||||
|
/// This has the same outcome as doing a "real" fetch, but suffices for the simulation, and
|
||||||
|
/// doesn't require running nodes.
|
||||||
|
pub fn fetch<W: WriteRepository>(
|
||||||
|
repo: &W,
|
||||||
|
node: &NodeId,
|
||||||
|
namespaces: impl Into<Namespaces>,
|
||||||
|
) -> Result<Vec<RefUpdate>, crate::storage::FetchError> {
|
||||||
|
let namespace = match namespaces.into() {
|
||||||
|
Namespaces::All => None,
|
||||||
|
Namespaces::Trusted(trusted) => trusted.into_iter().next(),
|
||||||
|
};
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
let mut callbacks = git2::RemoteCallbacks::new();
|
||||||
|
let mut opts = git2::FetchOptions::default();
|
||||||
|
let refspec = if let Some(namespace) = namespace {
|
||||||
|
opts.prune(git2::FetchPrune::On);
|
||||||
|
format!("refs/namespaces/{namespace}/refs/*:refs/namespaces/{namespace}/refs/*")
|
||||||
|
} else {
|
||||||
|
opts.prune(git2::FetchPrune::Off);
|
||||||
|
"refs/namespaces/*:refs/namespaces/*".to_owned()
|
||||||
|
};
|
||||||
|
|
||||||
|
callbacks.update_tips(|name, old, new| {
|
||||||
|
if let Ok(name) = crate::git::RefString::try_from(name) {
|
||||||
|
if name.to_namespaced().is_some() {
|
||||||
|
updates.push(RefUpdate::from(name, old, new));
|
||||||
|
// Returning `true` ensures the process is not aborted.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
opts.remote_callbacks(callbacks);
|
||||||
|
|
||||||
|
let mut remote = repo.raw().remote_anonymous(
|
||||||
|
crate::storage::git::transport::remote::Url {
|
||||||
|
node: *node,
|
||||||
|
repo: repo.id(),
|
||||||
|
namespace,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
.as_str(),
|
||||||
|
)?;
|
||||||
|
remote.fetch(&[refspec], Some(&mut opts), None)?;
|
||||||
|
|
||||||
|
drop(opts);
|
||||||
|
|
||||||
|
repo.set_identity_head()?;
|
||||||
|
repo.set_head()?;
|
||||||
|
repo.validate()?;
|
||||||
|
|
||||||
|
Ok(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod setup {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::storage::{Namespaces, RefUpdate};
|
||||||
use crate::crypto::test::signer::MockSigner;
|
use crate::crypto::test::signer::MockSigner;
|
||||||
use crate::prelude::*;
|
use crate::storage::git::transport::remote;
|
||||||
use crate::{
|
use crate::{
|
||||||
git,
|
git,
|
||||||
profile::Home,
|
profile::Home,
|
||||||
|
|
@ -16,49 +77,100 @@ pub mod setup {
|
||||||
test::{fixtures, storage::git::Repository},
|
test::{fixtures, storage::git::Repository},
|
||||||
Storage,
|
Storage,
|
||||||
};
|
};
|
||||||
|
use crate::{prelude::*, rad};
|
||||||
|
|
||||||
#[derive(Debug)]
|
/// A node.
|
||||||
pub struct BranchWith {
|
///
|
||||||
pub base: git::Oid,
|
/// Note that this isn't a real node; only a profile with storage and a signing key.
|
||||||
pub oid: git::Oid,
|
pub struct Node {
|
||||||
}
|
pub root: PathBuf,
|
||||||
|
|
||||||
pub struct Context {
|
|
||||||
pub storage: Storage,
|
pub storage: Storage,
|
||||||
pub signer: MockSigner,
|
pub signer: MockSigner,
|
||||||
pub project: Repository,
|
|
||||||
pub working: git2::Repository,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Context {
|
impl Default for Node {
|
||||||
pub fn new(tmp: &TempDir) -> Self {
|
fn default() -> Self {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
Self::new(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Node {
|
||||||
|
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||||
|
let root = root.as_ref().to_path_buf();
|
||||||
let mut rng = fastrand::Rng::new();
|
let mut rng = fastrand::Rng::new();
|
||||||
let signer = MockSigner::new(&mut rng);
|
let signer = MockSigner::new(&mut rng);
|
||||||
let home = tmp.path().join("home");
|
let home = root.join("home");
|
||||||
let paths = Home::new(home.as_path()).unwrap();
|
let paths = Home::new(home.as_path()).unwrap();
|
||||||
let storage = Storage::open(paths.storage()).unwrap();
|
let storage = Storage::open(paths.storage()).unwrap();
|
||||||
let (id, _, working, _) =
|
|
||||||
fixtures::project(tmp.path().join("copy"), &storage, &signer).unwrap();
|
remote::mock::register(signer.public_key(), storage.path());
|
||||||
let project = storage.repository(id).unwrap();
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
root,
|
||||||
storage,
|
storage,
|
||||||
signer,
|
signer,
|
||||||
project,
|
|
||||||
working,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn branch_with(
|
pub fn clone(&mut self, rid: Id, other: &Self) {
|
||||||
|
let repo = self.storage.create(rid).unwrap();
|
||||||
|
super::fetch(&repo, other.signer.public_key(), Namespaces::All).unwrap();
|
||||||
|
|
||||||
|
rad::fork(rid, &self.signer, &self.storage).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project(&self) -> NodeRepo {
|
||||||
|
let (id, _, checkout, _) =
|
||||||
|
fixtures::project(self.root.join("working"), &self.storage, &self.signer).unwrap();
|
||||||
|
let repo = self.storage.repository(id).unwrap();
|
||||||
|
let checkout = Some(NodeRepoCheckout { checkout });
|
||||||
|
|
||||||
|
NodeRepo { repo, checkout }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node repository with an optional checkout.
|
||||||
|
pub struct NodeRepo {
|
||||||
|
pub repo: Repository,
|
||||||
|
pub checkout: Option<NodeRepoCheckout>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NodeRepo {
|
||||||
|
pub fn fetch(&self, from: &Node) -> Vec<RefUpdate> {
|
||||||
|
super::fetch(&self.repo, from.signer.public_key(), Namespaces::All).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkout(&self) -> &NodeRepoCheckout {
|
||||||
|
self.checkout.as_ref().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for NodeRepo {
|
||||||
|
type Target = Repository;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A repository checkout.
|
||||||
|
pub struct NodeRepoCheckout {
|
||||||
|
checkout: git::raw::Repository,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NodeRepoCheckout {
|
||||||
|
pub fn branch_with<S: AsRef<Path>, T: AsRef<[u8]>>(
|
||||||
&self,
|
&self,
|
||||||
blobs: impl IntoIterator<Item = (String, Vec<u8>)>,
|
blobs: impl IntoIterator<Item = (S, T)>,
|
||||||
) -> BranchWith {
|
) -> BranchWith {
|
||||||
let refname = git::Qualified::from(git::lit::refs_heads(git::refname!("master")));
|
let refname = git::Qualified::from(git::lit::refs_heads(git::refname!("master")));
|
||||||
let base = self.working.refname_to_id(refname.as_str()).unwrap();
|
let base = self.checkout.refname_to_id(refname.as_str()).unwrap();
|
||||||
let parent = self.working.find_commit(base).unwrap();
|
let parent = self.checkout.find_commit(base).unwrap();
|
||||||
let oid = commit(&self.working, &refname, blobs, &[&parent]);
|
let oid = commit(&self.checkout, &refname, blobs, &[&parent]);
|
||||||
|
|
||||||
git::push(&self.working, &REMOTE_NAME, [(&refname, &refname)]).unwrap();
|
git::push(&self.checkout, &REMOTE_NAME, [(&refname, &refname)]).unwrap();
|
||||||
|
|
||||||
BranchWith {
|
BranchWith {
|
||||||
base: base.into(),
|
base: base.into(),
|
||||||
|
|
@ -67,37 +179,116 @@ pub mod setup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn initial_blobs() -> Vec<(String, Vec<u8>)> {
|
impl std::ops::Deref for NodeRepoCheckout {
|
||||||
vec![
|
type Target = git::raw::Repository;
|
||||||
("README.md".to_string(), b"Hello, World!".to_vec()),
|
|
||||||
(
|
fn deref(&self) -> &Self::Target {
|
||||||
"CONTRIBUTING".to_string(),
|
&self.checkout
|
||||||
b"Please follow the rules".to_vec(),
|
}
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_blobs() -> Vec<(String, Vec<u8>)> {
|
/// A node with a repository.
|
||||||
vec![
|
pub struct NodeWithRepo {
|
||||||
("README.md".to_string(), b"Hello, Radicle!".to_vec()),
|
pub node: Node,
|
||||||
(
|
pub repo: NodeRepo,
|
||||||
"CONTRIBUTING".to_string(),
|
|
||||||
b"Please follow the rules".to_vec(),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commit(
|
impl std::ops::Deref for NodeWithRepo {
|
||||||
|
type Target = Node;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::DerefMut for NodeWithRepo {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NodeWithRepo {
|
||||||
|
fn default() -> Self {
|
||||||
|
let node = Node::default();
|
||||||
|
let repo = node.project();
|
||||||
|
|
||||||
|
Self { node, repo }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A network of three nodes.
|
||||||
|
///
|
||||||
|
/// Note that these are not actually running nodes in the sense of `radicle-node`.
|
||||||
|
/// These are simply profiles with their own storage, and the ability to fetch between
|
||||||
|
/// them.
|
||||||
|
pub struct Network {
|
||||||
|
pub alice: NodeWithRepo,
|
||||||
|
pub bob: NodeWithRepo,
|
||||||
|
pub eve: NodeWithRepo,
|
||||||
|
pub rid: Id,
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
tmp: tempfile::TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Network {
|
||||||
|
fn default() -> Self {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let alice = Node::new(tmp.path().join("alice"));
|
||||||
|
let mut bob = Node::new(tmp.path().join("bob"));
|
||||||
|
let mut eve = Node::new(tmp.path().join("eve"));
|
||||||
|
let repo = alice.project();
|
||||||
|
let rid = repo.id;
|
||||||
|
|
||||||
|
bob.clone(repo.id, &alice);
|
||||||
|
eve.clone(repo.id, &alice);
|
||||||
|
|
||||||
|
let alice = NodeWithRepo { node: alice, repo };
|
||||||
|
let repo = bob.storage.repository(rid).unwrap();
|
||||||
|
let bob = NodeWithRepo {
|
||||||
|
node: bob,
|
||||||
|
repo: NodeRepo {
|
||||||
|
repo,
|
||||||
|
checkout: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let repo = eve.storage.repository(rid).unwrap();
|
||||||
|
let eve = NodeWithRepo {
|
||||||
|
node: eve,
|
||||||
|
repo: NodeRepo {
|
||||||
|
repo,
|
||||||
|
checkout: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
alice,
|
||||||
|
bob,
|
||||||
|
eve,
|
||||||
|
rid,
|
||||||
|
tmp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BranchWith {
|
||||||
|
pub base: git::Oid,
|
||||||
|
pub oid: git::Oid,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit<S: AsRef<Path>, T: AsRef<[u8]>>(
|
||||||
repo: &git2::Repository,
|
repo: &git2::Repository,
|
||||||
refname: &git::Qualified,
|
refname: &git::Qualified,
|
||||||
blobs: impl IntoIterator<Item = (String, Vec<u8>)>,
|
blobs: impl IntoIterator<Item = (S, T)>,
|
||||||
parents: &[&git2::Commit<'_>],
|
parents: &[&git2::Commit<'_>],
|
||||||
) -> git::Oid {
|
) -> git::Oid {
|
||||||
let tree = {
|
let tree = {
|
||||||
let mut tb = repo.treebuilder(None).unwrap();
|
let mut tb = repo.treebuilder(None).unwrap();
|
||||||
for (name, blob) in blobs.into_iter() {
|
for (name, blob) in blobs.into_iter() {
|
||||||
let oid = repo.blob(&blob).unwrap();
|
let oid = repo.blob(blob.as_ref()).unwrap();
|
||||||
tb.insert(name, oid, git2::FileMode::Blob.into()).unwrap();
|
tb.insert(name.as_ref(), oid, git2::FileMode::Blob.into())
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
tb.write().unwrap()
|
tb.write().unwrap()
|
||||||
};
|
};
|
||||||
|
|
@ -108,7 +299,7 @@ pub mod setup {
|
||||||
Some(refname.as_str()),
|
Some(refname.as_str()),
|
||||||
&author,
|
&author,
|
||||||
&author,
|
&author,
|
||||||
"test commit",
|
"Making changes",
|
||||||
&tree,
|
&tree,
|
||||||
parents,
|
parents,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue