rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood149b06a6d1200b996a4441c8e4df4a2458628290
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "c34e0c1940da85c02228d7b20168403c2a98e9ac",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"title": "fetch: Rewrite `git::repository::direct`",
"state": {
"status": "open",
"conflicts": []
},
"before": "4787b53b1e85d8052744fc77e4160e4d90e46d0f",
"after": "149b06a6d1200b996a4441c8e4df4a2458628290",
"commits": [
"149b06a6d1200b996a4441c8e4df4a2458628290"
],
"target": "4787b53b1e85d8052744fc77e4160e4d90e46d0f",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "c34e0c1940da85c02228d7b20168403c2a98e9ac",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "This function (and the helpers `ancestry` and `find_and_peel`) are\neagerly peeling to commits, leading to updates of tags that target the\nsame commit to be missed.\n\nFor example, there could be tag two tag objects with *different* OIDs\nA and B, from *different* authors, using *different* tag names, signed\nwith *different* secret keys, and both pointing to *the same* commit C.\n\nThe implementation would consider A and B to be the same, just becuase\nA and B both peel to C, skipping the update.\n\nThis is counterintuitive, and when combined with canonical references\ncan be quite confusing.\n\nChange this to only reason about an ancestry if the two objects in\nquestion really both are commits directly. Otherwise, treat cases where\nno structure can be used as ancestry similarly to non-fast-forward\nupdates.",
"base": "4787b53b1e85d8052744fc77e4160e4d90e46d0f",
"oid": "149b06a6d1200b996a4441c8e4df4a2458628290",
"timestamp": 1759252535
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "514d45ff-32dc-4a46-b300-fbb2a03ba3f1"
},
"info_url": "https://cci.rad.levitte.org//514d45ff-32dc-4a46-b300-fbb2a03ba3f1.html"
}
Started at: 2025-09-30 19:30:35.331403+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 123 issues · 18 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout c34e0c1940da85c02228d7b20168403c2a98e9ac
✓ Switched to branch patch/c34e0c1 at revision c34e0c1
✓ Branch patch/c34e0c1 setup to track rad/patches/c34e0c1940da85c02228d7b20168403c2a98e9ac
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 149b06a6d1200b996a4441c8e4df4a2458628290
HEAD is now at 149b06a6 fetch: Rewrite `git::repository::direct`
Exit code: 0
$ git show 149b06a6d1200b996a4441c8e4df4a2458628290
commit 149b06a6d1200b996a4441c8e4df4a2458628290
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Tue Sep 30 13:13:46 2025 +0200
fetch: Rewrite `git::repository::direct`
This function (and the helpers `ancestry` and `find_and_peel`) are
eagerly peeling to commits, leading to updates of tags that target the
same commit to be missed.
For example, there could be tag two tag objects with *different* OIDs
A and B, from *different* authors, using *different* tag names, signed
with *different* secret keys, and both pointing to *the same* commit C.
The implementation would consider A and B to be the same, just becuase
A and B both peel to C, skipping the update.
This is counterintuitive, and when combined with canonical references
can be quite confusing.
Change this to only reason about an ancestry if the two objects in
question really both are commits directly. Otherwise, treat cases where
no structure can be used as ancestry similarly to non-fast-forward
updates.
diff --git a/crates/radicle-fetch/src/git/repository.rs b/crates/radicle-fetch/src/git/repository.rs
index 806e3e34..2b9037a7 100644
--- a/crates/radicle-fetch/src/git/repository.rs
+++ b/crates/radicle-fetch/src/git/repository.rs
@@ -58,18 +58,25 @@ fn find_and_peel(repo: &Repository, oid: Oid) -> Result<Oid, error::Ancestry> {
}
}
+/// Peels the two objects to commits (see [`find_and_peel`]) and determines
+/// their ancestry relationship (see [`ahead_behind`]).
pub fn ancestry(repo: &Repository, old: Oid, new: Oid) -> Result<Ancestry, error::Ancestry> {
let old = find_and_peel(repo, old)?;
let new = find_and_peel(repo, new)?;
- if old == new {
+ ahead_behind(repo, old, new)
+}
+
+/// Determine the ancestry relationship between two commits.
+pub fn ahead_behind(repo: &Repository, old_commit: Oid, new_commit: Oid) -> Result<Ancestry, error::Ancestry> {
+ if old_commit == new_commit {
return Ok(Ancestry::Equal);
}
let (ahead, behind) = repo
.backend
- .graph_ahead_behind(*new, *old)
- .map_err(|err| error::Ancestry::Check { old, new, err })?;
+ .graph_ahead_behind(*new_commit, *old_commit)
+ .map_err(|err| error::Ancestry::Check { old: old_commit, new: new_commit, err })?;
if ahead > 0 && behind == 0 {
Ok(Ancestry::Ahead)
@@ -128,77 +135,133 @@ fn direct<'a>(
target: Oid,
no_ff: Policy,
) -> Result<Updated<'a>, error::Update> {
- let tip = refname_to_id(repo, name.clone())?;
- match tip {
- Some(prev) => {
- let ancestry = ancestry(repo, prev, target)?;
-
- match ancestry {
- Ancestry::Equal => Ok(RefUpdate::Skipped {
- name: name.to_ref_string(),
+ let Some(reference) = find(repo, &name)? else {
+ repo.backend
+ .reference(name.as_ref(), target.into(), false, "radicle: create")
+ .map_err(|err| error::Update::Create {
+ name: name.to_owned(),
+ target,
+ err,
+ })?;
+
+ return Ok(RefUpdate::Created {
+ name: name.to_ref_string(),
+ oid: target,
+ }
+ .into())
+ };
+
+ let Some(prev) = reference.target() else {
+ // This should never happen, as there are no facilities to create
+ // symbolic references in Radicle namespaces. If it does, e.g. because
+ // some external program or the user themselves created it, we better
+ // do not touch it.
+ return Err(error::Update::Symbolic { name: name.to_owned() });
+ };
+
+ let ancestry = {
+ use git::raw::ObjectType::{self, *};
+ const ANY_KIND: Option<ObjectType> = Some(Any);
+
+ let prev = repo
+ .backend
+ .find_object(prev, ANY_KIND).map_err(|err| {
+ error::Update::Ancestry(error::Ancestry::Object {
+ oid: prev.into(),
+ err
+ })
+ })?;
+
+ let target = repo
+ .backend
+ .find_object(*target, ANY_KIND).map_err(|err| {
+ error::Update::Ancestry(error::Ancestry::Object {
oid: target,
- }
- .into()),
- Ancestry::Ahead => {
- // N.b. the update is a fast-forward so we can safely
- // pass `force: true`.
- repo.backend
- .reference(name.as_ref(), target.into(), true, "radicle: update")
- .map_err(|err| error::Update::Create {
- name: name.to_owned(),
- target,
- err,
- })?;
- Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
- }
- Ancestry::Behind | Ancestry::Diverged if matches!(no_ff, Policy::Allow) => {
- // N.b. the update is a non-fast-forward but
- // we allow it, so we pass `force: true`.
- repo.backend
- .reference(name.as_ref(), target.into(), true, "radicle: update")
- .map_err(|err| error::Update::Create {
- name: name.to_owned(),
- target,
- err,
- })?;
- Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
- }
- // N.b. if the target is behind, we simply reject the update
- Ancestry::Behind => Ok(Update::Direct {
- name,
- target,
- no_ff,
- }
- .into()),
- Ancestry::Diverged if matches!(no_ff, Policy::Reject) => Ok(Update::Direct {
- name,
- target,
- no_ff,
- }
- .into()),
- Ancestry::Diverged => Err(error::Update::NonFF {
- name: name.to_owned(),
- new: target,
- cur: prev,
- }),
+ err,
+ })
+ })?;
+
+ if prev.id() == target.id() {
+ // If the two objects are identical, their ancestry does not matter,
+ // we can always skip the update.
+ return Ok(RefUpdate::Skipped {
+ name: name.to_ref_string(),
+ oid: target.id().into(),
+ }.into());
+ }
+
+ match (prev.kind(), target.kind()) {
+ (Some(Commit), Some(Commit)) => {
+ // This is the common case, we have two commits to compare.
+ let prev = prev.id().into();
+ let target = target.id().into();
+ Some(ahead_behind(repo, prev, target)?)
+ },
+ (Some(Tag), Some(Tag)) => {
+ // Even though these tags might point to the same commit,
+ // refuse to peel, because that tag itself has changed
+ // (e.g. its name or signature).
+ None
+ },
+ (Some(Commit | Tag), Some(Commit | Tag)) => {
+ // The reference changes from a commit to a tag or vice versa.
+ None
+ },
+ _ => {
+ // One of the objects is not a commit or a tag, we're clueless.
+ None
}
}
- None => {
- // N.b. the reference didn't exist so we pass `force:
- // false`.
+ };
+
+ match ancestry {
+ Some(Ancestry::Equal) => Ok(RefUpdate::Skipped {
+ name: name.to_ref_string(),
+ oid: target,
+ }
+ .into()),
+ Some(Ancestry::Ahead) => {
+ // N.b. the update is a fast-forward so we can safely
+ // pass `force: true`.
repo.backend
- .reference(name.as_ref(), target.into(), false, "radicle: create")
+ .reference(name.as_ref(), target.into(), true, "radicle: update")
.map_err(|err| error::Update::Create {
name: name.to_owned(),
target,
err,
})?;
- Ok(RefUpdate::Created {
- name: name.to_ref_string(),
- oid: target,
- }
- .into())
+ Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
}
+ Some(Ancestry::Behind | Ancestry::Diverged) | None if matches!(no_ff, Policy::Allow) => {
+ // N.b. the update is a non-fast-forward but
+ // we allow it, so we pass `force: true`.
+ repo.backend
+ .reference(name.as_ref(), target.into(), true, "radicle: update")
+ .map_err(|err| error::Update::Create {
+ name: name.to_owned(),
+ target,
+ err,
+ })?;
+ Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
+ }
+ // N.b. if the target is behind, we simply reject the update
+ Some(Ancestry::Behind) => Ok(Update::Direct {
+ name,
+ target,
+ no_ff,
+ }
+ .into()),
+ Some(Ancestry::Diverged) | None if matches!(no_ff, Policy::Reject) => Ok(Update::Direct {
+ name,
+ target,
+ no_ff,
+ }
+ .into()),
+ Some(Ancestry::Diverged) | None => Err(error::Update::NonFF {
+ name: name.to_owned(),
+ new: target,
+ cur: prev.into(),
+ }),
}
}
diff --git a/crates/radicle-fetch/src/git/repository/error.rs b/crates/radicle-fetch/src/git/repository/error.rs
index 6c2d546c..268e8f6f 100644
--- a/crates/radicle-fetch/src/git/repository/error.rs
+++ b/crates/radicle-fetch/src/git/repository/error.rs
@@ -79,4 +79,7 @@ pub enum Update {
Peel(#[source] raw::Error),
#[error(transparent)]
Resolve(#[from] Resolve),
+
+ #[error("refusing to update symbolic ref {name}")]
+ Symbolic { name: Namespaced<'static> },
}
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 514d45ff-32dc-4a46-b300-fbb2a03ba3f1 -v /opt/radcis/ci.rad.levitte.org/cci/state/514d45ff-32dc-4a46-b300-fbb2a03ba3f1/s:/514d45ff-32dc-4a46-b300-fbb2a03ba3f1/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w:/514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w -w /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:bookworm bash /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.88-x86_64-unknown-linux-gnu'
info: latest update on 2025-06-26, rust version 1.88.0 (6b00bc388 2025-06-23)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.88.0 (873a06493 2025-05-10)
+ rustc --version
rustc 1.88.0 (6b00bc388 2025-06-23)
+ cargo fmt --check
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:68:
}
/// Determine the ancestry relationship between two commits.
-pub fn ahead_behind(repo: &Repository, old_commit: Oid, new_commit: Oid) -> Result<Ancestry, error::Ancestry> {
+pub fn ahead_behind(
+ repo: &Repository,
+ old_commit: Oid,
+ new_commit: Oid,
+) -> Result<Ancestry, error::Ancestry> {
if old_commit == new_commit {
return Ok(Ancestry::Equal);
}
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:76:
let (ahead, behind) = repo
.backend
.graph_ahead_behind(*new_commit, *old_commit)
- .map_err(|err| error::Ancestry::Check { old: old_commit, new: new_commit, err })?;
+ .map_err(|err| error::Ancestry::Check {
+ old: old_commit,
+ new: new_commit,
+ err,
+ })?;
if ahead > 0 && behind == 0 {
Ok(Ancestry::Ahead)
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:143:
target,
err,
})?;
-
+
return Ok(RefUpdate::Created {
name: name.to_ref_string(),
oid: target,
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:150:
}
- .into())
+ .into());
};
let Some(prev) = reference.target() else {
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:156:
// symbolic references in Radicle namespaces. If it does, e.g. because
// some external program or the user themselves created it, we better
// do not touch it.
- return Err(error::Update::Symbolic { name: name.to_owned() });
+ return Err(error::Update::Symbolic {
+ name: name.to_owned(),
+ });
};
let ancestry = {
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:163:
use git::raw::ObjectType::{self, *};
const ANY_KIND: Option<ObjectType> = Some(Any);
- let prev = repo
- .backend
- .find_object(prev, ANY_KIND).map_err(|err| {
- error::Update::Ancestry(error::Ancestry::Object {
- oid: prev.into(),
- err
- })
- })?;
+ let prev = repo.backend.find_object(prev, ANY_KIND).map_err(|err| {
+ error::Update::Ancestry(error::Ancestry::Object {
+ oid: prev.into(),
+ err,
+ })
+ })?;
let target = repo
.backend
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:177:
- .find_object(*target, ANY_KIND).map_err(|err| {
- error::Update::Ancestry(error::Ancestry::Object {
- oid: target,
- err,
- })
- })?;
+ .find_object(*target, ANY_KIND)
+ .map_err(|err| error::Update::Ancestry(error::Ancestry::Object { oid: target, err }))?;
if prev.id() == target.id() {
// If the two objects are identical, their ancestry does not matter,
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:187:
return Ok(RefUpdate::Skipped {
name: name.to_ref_string(),
oid: target.id().into(),
- }.into());
+ }
+ .into());
}
match (prev.kind(), target.kind()) {
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:196:
let prev = prev.id().into();
let target = target.id().into();
Some(ahead_behind(repo, prev, target)?)
- },
+ }
(Some(Tag), Some(Tag)) => {
// Even though these tags might point to the same commit,
// refuse to peel, because that tag itself has changed
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:203:
// (e.g. its name or signature).
None
- },
+ }
(Some(Commit | Tag), Some(Commit | Tag)) => {
// The reference changes from a commit to a tag or vice versa.
None
Diff in /514d45ff-32dc-4a46-b300-fbb2a03ba3f1/w/crates/radicle-fetch/src/git/repository.rs:209:
- },
+ }
_ => {
// One of the objects is not a commit or a tag, we're clueless.
None
Exit code: 1
{
"response": "finished",
"result": "failure"
}