radicle/sigrefs: Automatically Migrate

In order to allow automatic migration to the feature level 'parent',
introduce a way to force writing signed references, even if the
user-controlled refs are unchanged.

To detect the special case of a Signed References history with a single
and root commit (which would otherwise be detected as feature level
'parent', even though the `refs/rad/sigrefs-parent` ref cannot be
written), keep information about the parent commit in `SignedRefs`.

The output of `rad inspect --sigrefs` is changed to match the more
strict interpretation of the feature level for histories with a single
and root commit.
This commit is contained in:
Lorenz Leutgeb 2026-03-22 18:44:09 +01:00 committed by Fintan Halpenny
parent 8b166b2327
commit 47063057a8
22 changed files with 302 additions and 126 deletions

View File

@ -25,6 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
will appear as a downgrade attacker.
- The `rad inspect --sigrefs` command will now output the feature level of the
signed references entry for each node.
- The signed references for the local node are automatically migrated when
`radicle-node` first starts up. The migration occurs for each repository in
the node's inventory, where the node has a reference
`refs/namespaces/<NID>/refs/rad/sigrefs`. The migration will only take place
if the `rad/sigrefs` found were below the latest feature level, i.e. `parent`.
## 1.7.1

View File

@ -18,8 +18,8 @@ Let's also inspect what remotes are in the repository:
``` ~alice
$ rad inspect --sigrefs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 99c549702e2bcfe02b0e68d4a2224fb7a1524529 parent
z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk e9f48ef90fe8592e1b1c95f96c21a59ca1495300 parent
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 990bb9984b28aa09ed9f5e32f16e92b3eed57d34 parent
z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk d275a070a9ed705f44a42271f9e3c5b6c73c65b3 parent
```
Now let's clean the `heartwood` project:
@ -34,7 +34,7 @@ Inspecting the remotes again, we see that Bob is now gone:
``` ~alice
$ rad inspect --sigrefs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 99c549702e2bcfe02b0e68d4a2224fb7a1524529 parent
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 990bb9984b28aa09ed9f5e32f16e92b3eed57d34 parent
```
Note that Bob will be fetched again if we do not untrack his

View File

@ -1,20 +1,14 @@
``` ~alice
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Add Bob" --description "" --threshold 2 --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm -q
069e7d58faa9a7473d27f5510d676af33282796f
$ rad inspect --sigrefs rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi [..] parent
z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk [..] parent
z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z 82212c030fa97709a92ead14be76759732199c61 parent
```
A note for test authors:
> The following `rad watch` command will time out if the target given via `-t` changes.
> This happens for example when the generation of sigrefs changes.
> To recover, temporarily change from `rad watch` to something like
>
> $ sleep 5
> $ rad inspect --sigrefs rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
>
> And pick out the result for `z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z`.
``` ~bob
$ rad watch --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --node z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z -r 'refs/rad/sigrefs' -t c9a828fc2fb01f893d6e6e9e17b9092dea2b3aba -i 500 --timeout 5000ms
$ rad watch --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --node z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z -r 'refs/rad/sigrefs' -t 82212c030fa97709a92ead14be76759732199c61 -i 500 --timeout 5000ms
$ rad sync --fetch rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
@ -28,7 +22,7 @@ $ rad id --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --sigrefs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi [..] parent
z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk [..] parent
z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk [..] root
z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z [..] parent
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --delegates
did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (alice)

View File

@ -148,9 +148,10 @@ z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
Similarly, she still does not have Bob's `rad/sigrefs`:
``` ~alice
$ rad inspect --sigrefs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi e0e55994a9a234f0b1cd36d8812e2948e2672b7a parent
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 709795db0cd91451b533da83a19a4cada3f5f74f parent
```
And she can still list the project, without any worries:
@ -198,6 +199,6 @@ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential s
🌱 Fetched from z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z
🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
$ rad inspect --sigrefs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi e0e55994a9a234f0b1cd36d8812e2948e2672b7a parent
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 709795db0cd91451b533da83a19a4cada3f5f74f parent
z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk dace6fe948548168a2bb687718949d9b5d9076ee parent
```

View File

@ -34,7 +34,7 @@ And sigrefs:
```
$ rad inspect --sigrefs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 99c549702e2bcfe02b0e68d4a2224fb7a1524529 parent
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 99c549702e2bcfe02b0e68d4a2224fb7a1524529 root
```
Or display the repository identity's payload and delegates:

View File

@ -15,9 +15,9 @@ $ rad sync status --sort-by alias
╭───────────────────────────────────────────────────╮
│ Node ID Alias ? SigRefs Timestamp │
├───────────────────────────────────────────────────┤
│ (you) alice ! f2dfe80 [..] │
│ z6Mkt67…v4N1tRk bob ✗ 99c5497 [..] │
│ z6Mkux1…nVhib7Z eve ✗ 99c5497 [..] │
│ (you) alice ! ac4a256 [..] │
│ z6Mkt67…v4N1tRk bob ✗ 990bb99 [..] │
│ z6Mkux1…nVhib7Z eve ✗ 990bb99 [..] │
╰───────────────────────────────────────────────────╯
```
@ -37,9 +37,9 @@ $ rad sync status --sort-by alias
╭───────────────────────────────────────────────────╮
│ Node ID Alias ? SigRefs Timestamp │
├───────────────────────────────────────────────────┤
│ (you) alice ✓ f2dfe80 [..] │
│ z6Mkt67…v4N1tRk bob ✓ f2dfe80 [..] │
│ z6Mkux1…nVhib7Z eve ✓ f2dfe80 [..] │
│ (you) alice ✓ ac4a256 [..] │
│ z6Mkt67…v4N1tRk bob ✓ ac4a256 [..] │
│ z6Mkux1…nVhib7Z eve ✓ ac4a256 [..] │
╰───────────────────────────────────────────────────╯
```

View File

@ -76,7 +76,20 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
term::format::secondary(refs.at),
match sigrefs {
Ok(refs) => {
let level = refs.feature_level();
let mut level = refs.feature_level();
// For their own refs, be more strict, and interpret
// `FeatureLevel::Parent` at a root commit as
// `FeatureLevel::Root`. This is so that users
// have a chance of detecting that automatic migration
// did not run or is otherwise broken.
if &remote == profile.id()
&& level == FeatureLevel::Parent
&& refs.parent().is_none()
{
level = FeatureLevel::Root;
}
let s = level.to_string();
match level {
FeatureLevel::None => term::format::negative(s),

View File

@ -1061,16 +1061,12 @@ fn test_refs_announcement_offline() {
let anns = alice
.messages(bob.id())
.filter_map(|m| {
if let Message::Announcement(Announcement {
.filter_map(|m| match m {
Message::Announcement(Announcement {
message: AnnouncementMessage::Refs(ann),
..
}) = m
{
Some(ann)
} else {
None
}
}) if ann.rid == rid => Some(ann),
_ => None,
})
.collect::<Vec<_>>();

View File

@ -257,6 +257,9 @@ fn test_replication_ref_in_sigrefs() {
.unwrap();
let mut alice = alice.spawn();
// At this point, bob will migrate sigrefs, because there only is a
// root commit in his `refs/heads/sigrefs`.
let bob = bob.spawn();
alice.connect(&bob);
@ -267,15 +270,19 @@ fn test_replication_ref_in_sigrefs() {
assert_matches!(result, FetchResult::Success { .. });
// alice still sees bob's master branch since it was in his
// sigrefs.
// Before automatic migration of sigrefs was introduced,
// alice would still see bob's master branch at this point and we
// would assert `.is_ok()`.
// With automatic migration, refs are signed as bob's node starts
// up, which is after he removes his ref locally, thus we now
// assert `.is_err()`.
assert!(
alice
.storage
.repository(acme)
.unwrap()
.reference(&bob.id, &git::fmt::qualified!("refs/heads/master"))
.is_ok(),
.is_err(),
"refs/namespaces/{}/refs/heads/master does not exist",
bob.id
);

View File

@ -528,20 +528,29 @@ where
return Ok(());
};
if refs.feature_level() >= FeatureLevel::LATEST {
if refs.feature_level() >= FeatureLevel::LATEST && refs.parent().is_some() {
// Refs are at target level or above, nothing to upgrade.
return Ok(());
}
log::info!(
"Migrating `rad/sigrefs` from level {} which is lower than target level {}.",
refs.feature_level(),
FeatureLevel::LATEST
);
let rid = info.rid;
if refs.parent().is_none() {
log::info!(
"Migrating `rad/sigrefs` of {rid} to force feature level {}, as the history currently contains only a root commit.",
FeatureLevel::LATEST
);
} else {
log::info!(
"Migrating `rad/sigrefs` of {rid} from level {} which is lower than target level {}.",
refs.feature_level(),
FeatureLevel::LATEST
);
}
let repo = self.storage.repository_mut(info.rid)?;
// NOTE: We assume to reach `FeatureLevel::MAX` by signing refs.
repo.sign_refs(&self.signer)?;
// NOTE: We assume to reach `FeatureLevel::LATEST` by signing refs.
repo.force_sign_refs(&self.signer)?;
Ok(())
}
}

View File

@ -672,6 +672,13 @@ pub trait SignRepository {
fn sign_refs<G>(&self, signer: &Device<G>) -> Result<SignedRefs, RepositoryError>
where
G: crypto::signature::Signer<crypto::Signature>;
/// Sign the repository's refs under the `refs/rad/sigrefs` branch, even if unchanged.
///
/// Most users will prefer [`Self::sign_refs`].
fn force_sign_refs<G>(&self, signer: &Device<G>) -> Result<SignedRefs, RepositoryError>
where
G: crypto::signature::Signer<crypto::Signature>;
}
impl<T, S> ReadStorage for T

View File

@ -993,6 +993,23 @@ impl SignRepository for Repository {
fn sign_refs<G: crypto::signature::Signer<crypto::Signature>>(
&self,
signer: &Device<G>,
) -> Result<SignedRefs, RepositoryError> {
self.sign_refs_with(signer, false)
}
fn force_sign_refs<G: crypto::signature::Signer<crypto::Signature>>(
&self,
signer: &Device<G>,
) -> Result<SignedRefs, RepositoryError> {
self.sign_refs_with(signer, true)
}
}
impl Repository {
fn sign_refs_with<G: crypto::signature::Signer<crypto::Signature>>(
&self,
signer: &Device<G>,
force: bool,
) -> Result<SignedRefs, RepositoryError> {
let remote = signer.public_key();
// Ensure the root reference is set, which is checked during sigref verification.
@ -1003,10 +1020,14 @@ impl SignRepository for Repository {
self.set_remote_identity_root(remote)?;
}
let committer = refs::sigrefs::git::committer(remote, &self.backend.signature()?)?;
let signed = self
.references_of(remote)?
.save(*remote, committer, self, signer)?;
let committer = refs::sigrefs::git::Committer::from_env_or_now(remote);
let refs = self.references_of(remote)?;
let signed = if force {
refs.force_save(*remote, committer, self, signer)?
} else {
refs.save(*remote, committer, self, signer)?
};
Ok(signed.sigrefs)
}

View File

@ -246,6 +246,13 @@ where
Ok(remote.refs)
}
fn force_sign_refs<G: crypto::signature::Signer<crypto::Signature>>(
&self,
signer: &Device<G>,
) -> Result<storage::refs::SignedRefs, RepositoryError> {
self.sign_refs(signer)
}
}
impl<R: storage::RemoteRepository> RemoteRepository for DraftStore<'_, R> {

View File

@ -77,6 +77,42 @@ impl Refs {
repo: &R,
signer: &S,
) -> Result<SignedRefsAt, Error>
where
R: sigrefs::git::object::Reader + sigrefs::git::object::Writer,
R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer,
R: HasRepoId,
S: signature::Signer<crypto::Signature>,
S: signature::Verifier<crypto::Signature>,
{
self.save_with(namespace, committer, repo, signer, false)
}
/// Save the signed refs to disk, even if the refs are unchanged.
pub fn force_save<R, S>(
self,
namespace: NodeId,
committer: sigrefs::git::Committer,
repo: &R,
signer: &S,
) -> Result<SignedRefsAt, Error>
where
R: sigrefs::git::object::Reader + sigrefs::git::object::Writer,
R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer,
R: HasRepoId,
S: signature::Signer<crypto::Signature>,
S: signature::Verifier<crypto::Signature>,
{
self.save_with(namespace, committer, repo, signer, true)
}
fn save_with<R, S>(
self,
namespace: NodeId,
committer: sigrefs::git::Committer,
repo: &R,
signer: &S,
force: bool,
) -> Result<SignedRefsAt, Error>
where
R: sigrefs::git::object::Reader + sigrefs::git::object::Writer,
R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer,
@ -86,9 +122,13 @@ impl Refs {
{
let msg = "Update signed refs\n";
let reflog = format!("Save {} signed references", self.len());
let update =
sigrefs::write::SignedRefsWriter::new(self, repo.rid(), namespace, repo, signer)
.write(committer, msg.to_string(), reflog)?;
let writer =
sigrefs::write::SignedRefsWriter::new(self, repo.rid(), namespace, repo, signer);
let update = if force {
writer.force_write(committer, msg.to_string(), reflog)?
} else {
writer.write(committer, msg.to_string(), reflog)?
};
match update {
sigrefs::write::Update::Changed { entry, level } => {
Ok(entry.into_sigrefs_at(namespace, level))
@ -287,6 +327,10 @@ pub struct SignedRefs {
#[serde(skip)]
level: FeatureLevel,
/// The [`Oid`] of the parent commit of the commit in which.
#[serde(skip)]
parent: Option<Oid>,
}
impl SignedRefs {
@ -305,6 +349,12 @@ impl SignedRefs {
self.level
}
/// The [`Oid`] of the parent commit, or [`None`] if these signed references
/// were found at a root commit.
pub fn parent(&self) -> Option<&Oid> {
self.parent.as_ref()
}
pub fn load<R>(remote: RemoteId, repo: &R) -> Result<Self, sigrefs::read::error::Read>
where
R: HasRepoId,

View File

@ -27,6 +27,7 @@ where
signature,
id: *signer.node_id(),
level: level.unwrap_or_else(|| FeatureLevel::arbitrary(g)),
parent: Arbitrary::arbitrary(g),
};
SignedRefsAt {
sigrefs,

View File

@ -1,21 +1,18 @@
//! The transparency log of Radicle signed references is encoded in the Git
//! commit graph. This module provides traits for interacting with a Git
//! repository to read and write data for the transparency log process.
//! Signed References are encoded in the Git commit graph.
//! This module provides traits for interacting with a Git
//! repository to read and write data for Signed References.
pub mod object;
pub mod reference;
pub use git2_impls::committer;
use crate::profile::env;
use crypto::PublicKey;
use radicle_git_metadata::author;
use radicle_git_metadata::author::Author;
use radicle_git_metadata::author::Time;
/// Convenience type that corresponds to an [`Author`].
///
/// If [`env::GIT_COMMITTER_DATE`] is set, then [`Committer::from_env`] can be
/// used to construct a stable [`Author`].
/// Most users will want to instantiate this via [`Committer::from_env_or_now`],
/// which automatically constructs a stable [`Author`] for tests as well.
///
/// Otherwise, an [`Author`] can be provided via [`Committer::new`].
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@ -24,13 +21,73 @@ pub struct Committer {
}
impl Committer {
/// Construct a [`Committer`] using [`Committer::from_env`], if possible,
/// using `default` if not.
pub fn from_env_or_else<F>(public_key: &PublicKey, default: F) -> Self
where
F: FnOnce() -> Author,
{
Self::from_env(public_key).unwrap_or_else(|| Self::new(default()))
/// Construct a [`Committer`] using the timestamp found at
/// [`GIT_COMMITTER_DATE`],
///
/// If [`GIT_COMMITTER_DATE`] is unset, it uses the current system
/// time.
///
/// The given [`PublicKey`] is always used for the email.
///
/// In test code, [`Committer::stable`] is returned.
///
/// [`GIT_COMMITTER_DATE`]: crate::profile::env::GIT_COMMITTER_DATE
pub fn from_env_or_now(public_key: &PublicKey) -> Self {
#[cfg(any(test, feature = "test"))]
return Self::stable(public_key);
#[cfg(not(any(test, feature = "test")))]
{
use crate::profile::env::GIT_COMMITTER_DATE;
use std::env::var;
use std::env::VarError;
let timestamp = match var(GIT_COMMITTER_DATE) {
Ok(s) => match s.trim().parse::<u64>() {
Ok(timestamp) => timestamp,
Err(err) => {
panic!(
"Value of environment variable `{}` does not parse as integer: {err}",
GIT_COMMITTER_DATE
);
}
},
Err(VarError::NotPresent) => std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.expect("time is later than unix epoch")
.as_secs(),
Err(VarError::NotUnicode(_)) => {
panic!(
"Value for environment variable `{}` is not valid Unicode.",
GIT_COMMITTER_DATE
);
}
};
let time = Time::new(
timestamp
.try_into()
.expect("seconds since unix epoch must fit i64"),
0,
);
let author = Author {
name: "radicle".to_string(),
email: public_key.to_human(),
time,
};
Self::new(author)
}
}
#[cfg(any(test, feature = "test"))]
pub fn stable(public_key: &PublicKey) -> Self {
let author = Author {
name: "radicle".to_string(),
email: public_key.to_human(),
time: Time::new(0, 0),
};
Self::new(author)
}
/// Construct a [`Committer`] with the provided [`Author`].
@ -38,25 +95,6 @@ impl Committer {
Self { author }
}
/// Construct a [`Committer`] using the timestamp found at
/// [`env::GIT_COMMITTER_DATE`], and the given [`PublicKey`] for the email.
pub fn from_env(public_key: &PublicKey) -> Option<Self> {
let s = env::var(env::GIT_COMMITTER_DATE).ok()?;
let Ok(timestamp) = s.trim().parse::<i64>() else {
panic!(
"Invalid timestamp value {s:?} for `{}`",
env::GIT_COMMITTER_DATE
);
};
let time = author::Time::new(timestamp, 0);
let author = Author {
name: "radicle".to_string(),
email: public_key.to_human(),
time,
};
Some(Self::new(author))
}
pub fn into_inner(self) -> Author {
self.author
}
@ -70,8 +108,6 @@ mod git2_impls {
use std::path::Path;
use radicle_core::NodeId;
use radicle_git_metadata::author::{Author, Time};
use radicle_oid::Oid;
use crate::git;
@ -79,38 +115,6 @@ mod git2_impls {
use super::object;
use super::object::{RefsEntry, SignatureEntry};
use super::reference;
use super::Committer;
pub fn committer(node: &NodeId, signature: &git2::Signature) -> Result<Committer, git2::Error> {
let default = {
let name = signature
.name()
.map(|name| name.to_string())
.ok_or(git2::Error::new(
git2::ErrorCode::Invalid,
git2::ErrorClass::Invalid,
"Invalid UTF-8 of Git signature name",
))?;
let email =
signature
.email()
.map(|email| email.to_string())
.ok_or(git2::Error::new(
git2::ErrorCode::Invalid,
git2::ErrorClass::Invalid,
"Invalid UTF-8 of Git signature email",
))?;
Author {
name,
email,
time: Time::new(
signature.when().seconds(),
signature.when().offset_minutes(),
),
}
};
Ok(Committer::from_env_or_else(node, || default))
}
impl object::Reader for git2::Repository {
fn read_commit(&self, oid: &Oid) -> Result<Option<Vec<u8>>, object::error::ReadCommit> {

View File

@ -1,7 +1,7 @@
//! Traits for interacting with Git objects, necessary for implementing Radicle
//! signed references.
//! Signed References.
// TODO(finto): I think these are more generally useful than just being used for
// signed references. They might be worth moving into a crate,
// Signed References. They might be worth moving into a crate,
// `radicle-git-traits`, but for now they can live here.
pub mod error;

View File

@ -1,7 +1,7 @@
//! Traits for interacting with Git references, necessary for implementing
//! Radicle signed references.
//! Radicle Signed References.
// TODO(finto): I think these are more generally useful than just being used for
// signed references. They might be worth moving into a crate,
// Signed References. They might be worth moving into a crate,
// `radicle-git-traits`, but for now they can live here.
pub mod error;

View File

@ -57,6 +57,7 @@ impl VerifiedCommit {
signature: self.commit.signature,
id,
level: self.level,
parent: self.commit.parent,
},
at: self.commit.oid,
}

View File

@ -119,11 +119,33 @@ where
/// ```text,no_run
/// refs/namespaces/<namespace>/refs/rad/sigrefs
/// ```
pub fn write(
pub(in super::super::super::refs) fn write(
self,
committer: Committer,
message: String,
reflog: String,
) -> Result<Update, error::Write> {
self.write_with(committer, message, reflog, false)
}
/// Write a commit even if the current sigrefs head contains identical refs.
///
/// Most users will prefer [`Self::write`].
pub(in super::super::super::refs) fn force_write(
self,
committer: Committer,
message: String,
reflog: String,
) -> Result<Update, error::Write> {
self.write_with(committer, message, reflog, true)
}
fn write_with(
self,
committer: Committer,
message: String,
reflog: String,
force: bool,
) -> Result<Update, error::Write> {
let author = committer.into_inner();
let Self {
@ -138,7 +160,7 @@ where
let head = HeadReader::new(&reference, repository, rid, self.signer).read();
let commit_writer = match head {
Ok(Some(head)) if head.is_unchanged(&refs) => {
Ok(Some(head)) if !force && head.is_unchanged(&refs) => {
return Ok(Update::unchanged(head.verified))
}
Ok(Some(head)) => CommitWriter::with_parent(
@ -190,6 +212,7 @@ impl Commit {
signature: self.signature,
refs: self.refs,
level,
parent: self.parent,
},
}
}

View File

@ -46,6 +46,14 @@ fn write(refs: Refs, repo: &MockRepository) -> Result<Update, error::Write> {
)
}
fn force_write(refs: Refs, repo: &MockRepository) -> Result<Update, error::Write> {
SignedRefsWriter::new(refs, mock::rid(), mock::node_id(), repo, &mock::AlwaysSign).force_write(
Committer::new(mock::author()),
"msg".into(),
"reflog".into(),
)
}
#[test]
fn head_error() {
let repo = MockRepository::new().with_rad_sigrefs_error(&mock::node_id());
@ -74,6 +82,28 @@ fn unchanged() {
}
}
#[test]
fn unchanged_force_writes_new_commit() {
let head = mock::oid(1);
let commit_oid = mock::oid(42);
let refs = some_refs(mock::oid(99));
let repo = MockRepository::new()
.with_rad_sigrefs(&mock::node_id(), head)
.with_commit(head, mock::commit_data([]))
.with_refs(head, refs.clone())
.with_signature(head, 1)
.with_write_tree_ok(mock::oid(99))
.with_write_commit_ok(commit_oid)
.with_write_reference_ok();
let update = force_write(refs.clone(), &repo).unwrap();
let Update::Changed { entry, .. } = update else {
panic!("expected Update::Changed, got {update:?}");
};
assert_eq!(entry.parent, Some(head));
assert_eq!(entry.oid, commit_oid);
assert_eq!(entry.into_refs(), refs);
}
#[test]
fn commit_error() {
let repo = MockRepository::new()

View File

@ -382,6 +382,13 @@ impl SignRepository for MockRepository {
) -> Result<crate::storage::refs::SignedRefs, RepositoryError> {
todo!()
}
fn force_sign_refs<G: crypto::signature::Signer<crypto::Signature>>(
&self,
_signer: &Device<G>,
) -> Result<crate::storage::refs::SignedRefs, RepositoryError> {
todo!()
}
}
impl radicle_cob::Store for MockRepository {}