Skip to content
This repository has been archived by the owner on Apr 29, 2024. It is now read-only.

Commit

Permalink
Prevent divergences in canonical head
Browse files Browse the repository at this point in the history
On push, we check whether the resulting state would cause a
divergence/fork in the canonical head, and if so, prevent the push from
happening.

This is to avoid situations where delegates have to then rollback their
heads.

Note that this doesn't prevent forks from happening altogether, as they
could happen asychronously, but it mitigates the problem.
  • Loading branch information
cloudhead committed Sep 27, 2023
1 parent a5a5538 commit c0271c3
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 5 deletions.
71 changes: 71 additions & 0 deletions radicle-cli/examples/git/git-push-diverge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@

Let's see what happens if we try to push a head which diverges from the
canonical head.

First we add a second delegate, Bob, to our repo:

``` ~alice
$ rad delegate add did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Added delegate 'did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk'
✓ Update successful!
$ rad remote add did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
✓ Remote bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk added
✓ Remote-tracking branch bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk/master created for z6Mkt67…v4N1tRk
```

Then, as Bob, we commit some code on top of the canonical head:

``` ~bob
$ rad sync --fetch
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi..
✓ Fetched repository from 1 seed(s)
$ rad inspect --delegates
did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (alice)
did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (bob)
$ git commit -m "Third commit" --allow-empty -q
$ git push rad
$ git branch -arv
alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master f2de534 Second commit
rad/master 319a7dc Third commit
```

As Alice, we fetch that code, but commit on top of our own master, which is no
longer canonical, since Bob pushed a more recent commit, and the threshold is 1:

``` ~alice
$ rad sync --fetch
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6Mkt67…v4N1tRk..
✓ Fetched repository from 1 seed(s)
$ git fetch bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
$ git branch -arv
bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk/master 319a7dc Third commit
rad/master f2de534 Second commit
$ git commit -m "Third commit by Alice" --allow-empty -q
```

If we try to push now, we get an error with a hint, telling us that we need to
integrate Bob's changes before pushing ours:

``` ~alice (stderr) (fail)
$ git push rad
hint: you are attempting to push a commit that would cause your upstream to diverge from the canonical head
hint: to integrate the remote changes, run `git pull --rebase` and try again
error: refusing to update branch to commit that is not a descendant of canonical head
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
```

We do that, and notice that we're now able to push our code:

``` ~alice
$ git pull --rebase
$ git log --oneline
f6cff86 Third commit by Alice
319a7dc Third commit
f2de534 Second commit
08c788d Initial commit
```
``` ~alice RAD_SOCKET=/dev/null (stderr)
$ git push rad
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
f2de534..f6cff86 master -> master
```
48 changes: 48 additions & 0 deletions radicle-cli/tests/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,54 @@ fn framework_home() {
.unwrap();
}

#[test]
fn git_push_diverge() {
logger::init(log::Level::Debug);

let mut environment = Environment::new();
let alice = environment.node(Config::test(Alias::new("alice")));
let bob = environment.node(Config::test(Alias::new("bob")));
let working = environment.tmp().join("working");

fixtures::repository(working.join("alice"));

test(
"examples/rad-init.md",
working.join("alice"),
Some(&alice.home),
[],
)
.unwrap();

let alice = alice.spawn();
let mut bob = bob.spawn();

bob.connect(&alice).converge([&alice]);

test(
"examples/rad-clone.md",
working.join("bob"),
Some(&bob.home),
[],
)
.unwrap();

formula(&environment.tmp(), "examples/git/git-push-diverge.md")
.unwrap()
.home(
"alice",
working.join("alice"),
[("RAD_HOME", alice.home.path().display())],
)
.home(
"bob",
working.join("bob").join("heartwood"),
[("RAD_HOME", bob.home.path().display())],
)
.run()
.unwrap();
}

#[test]
fn git_push_and_pull() {
logger::init(log::Level::Debug);
Expand Down
40 changes: 36 additions & 4 deletions radicle-remote-helper/src/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use thiserror::Error;

use radicle::cob::object::ParseObjectId;
use radicle::cob::patch;
use radicle::crypto::{PublicKey, Signer};
use radicle::crypto::Signer;
use radicle::identity::Did;
use radicle::node;
use radicle::node::{Handle, NodeId};
use radicle::prelude::Id;
Expand All @@ -28,11 +29,20 @@ const DEFAULT_SYNC_TIMEOUT: time::Duration = time::Duration::from_secs(9);
#[derive(Debug, Error)]
pub enum Error {
/// Public key doesn't match the remote namespace we're pushing to.
#[error("public key `{0}` does not match remote namespace")]
KeyMismatch(PublicKey),
#[error("cannot push to remote namespace owned by {0}")]
KeyMismatch(Did),
/// No public key is given
#[error("no public key given as a remote namespace, perhaps you are attempting to push to restricted refs")]
NoKey,
/// Head being pushed diverges from canonical head.
#[error("refusing to update branch to commit that is not a descendant of canonical head")]
HeadsDiverge(git::Oid, git::Oid),
/// Identity document error.
#[error("doc: {0}")]
Doc(#[from] radicle::identity::doc::DocError),
/// Identity payload error.
#[error("payload: {0}")]
Payload(#[from] radicle::identity::doc::PayloadError),
/// Invalid command received.
#[error("invalid command `{0}`")]
InvalidCommand(String),
Expand Down Expand Up @@ -141,7 +151,7 @@ pub fn run(
let nid = url.namespace.ok_or(Error::NoKey).and_then(|ns| {
(profile.public_key == ns)
.then_some(ns)
.ok_or(Error::KeyMismatch(profile.public_key))
.ok_or(Error::KeyMismatch(profile.public_key.into()))
})?;
let signer = profile.signer()?;
let mut line = String::new();
Expand All @@ -162,6 +172,8 @@ pub fn run(
_ => return Err(Error::InvalidCommand(line.trim().to_owned())),
}
}
let canonical = stored.head()?;
let delegates = stored.delegates()?;

// For each refspec, push a ref or delete a ref.
for spec in specs {
Expand Down Expand Up @@ -202,6 +214,26 @@ pub fn run(
opts.clone(),
)
} else {
let (canonical_ref, canonical_oid) = &canonical;

// If we're trying to update the canonical head, make sure
// we don't diverge from the current head.
if dst == *canonical_ref && delegates.contains(&Did::from(nid)) {
let head = working.find_reference(src.as_str())?;
let head = head.peel_to_commit()?.id();

if !working.graph_descendant_of(head, **canonical_oid)? {
eprintln!(
"hint: you are attempting to push a commit that would \
cause your upstream to diverge from the canonical head"
);
eprintln!(
"hint: to integrate the remote changes, run `git pull --rebase` \
and try again"
);
return Err(Error::HeadsDiverge(head.into(), *canonical_oid));
}
}
push(src, &dst, *force, &nid, &working, stored, &signer)
}
}
Expand Down
2 changes: 1 addition & 1 deletion radicle/src/storage/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ impl ReadRepository for Repository {
let (_, doc) = self.identity_doc()?;
let doc = doc.verified()?;
let project = doc.project()?;
let branch_ref = Qualified::from(lit::refs_heads(&project.default_branch()));
let branch_ref = git::refs::branch(project.default_branch());
let raw = self.raw();
let mut heads = Vec::new();

Expand Down

0 comments on commit c0271c3

Please sign in to comment.