rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood482641aada6acad92e42fe2f8bc7608bd443c5c7
{
"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": "5d72ffd1d518e92de2f0193c0ddfd9901b7153df",
"author": {
"id": "did:key:z6MkwGoyYxt6A2VE3fvZyH2rgiWdsXHBeV7jm7GSByS2aagA",
"alias": "ade"
},
"title": "remote-helper: prefactor to be more testable",
"state": {
"status": "open",
"conflicts": []
},
"before": "50fb228ae4c0c8f68413dc7922a1e2a32ace5655",
"after": "482641aada6acad92e42fe2f8bc7608bd443c5c7",
"commits": [
"482641aada6acad92e42fe2f8bc7608bd443c5c7",
"c3802dfae2b9c5728bb0054688b2280eff813bf5",
"d67e01474cd7d78307570a2ca1f95454e2dd62e6",
"d1d7a751d024be66f2d4f20c1012687937eadc9d",
"25d4a92d3138131860cda8ed44d06495affab71b",
"8048df940746dfce01f406c80d01a3e1df3f7ac6",
"1663d29e1f1383c0cbbd1d276a0c20469c119fa2",
"1414f84ff9644624ea7caf2c0ce150de1e84f5fd",
"d6499a1273c9ab266a3ff1e86030d6a53c790bf4"
],
"target": "90cf37c471ce531b90aabb048ceaca25996da2cf",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "5d72ffd1d518e92de2f0193c0ddfd9901b7153df",
"author": {
"id": "did:key:z6MkwGoyYxt6A2VE3fvZyH2rgiWdsXHBeV7jm7GSByS2aagA",
"alias": "ade"
},
"description": "To introduce new remote-helper changes like 'dont push by default' and\n'self' / 'rad' remotes, this crate has been refactored to ease testing.\n\n- the command parsing has been refactored into a sans i/o esque typed parser.\n- the radicle node interactions have been hoisted into a `NodeSession` trait.\n- the git `send_pack` and `fetch_pack` interactions have hoisted into `GitService` trait.\n- the direct stdin/out printing has been replaced with `BufRead` and `Writer` traits.\n\nremote-helper: refactor for_fetch and patch_refs to return vectors of strings\n\nprefactor to aid in testing remote-helper. return vectors of strings\nwhich can eventually become entries in a buffer.\n\n\nremote-helper: create GitService trait\n\nprefactor to introduce a GitService trait around the fetch_pack and\nsend_pack boundry.\n\n\nremote-helper: wire in the GitService\n\n\nremote-helper: create NodeSession trait and Real impl\n\nprefactor to add a trait over the Node boundary\n\n\nremote-helper: wire in NodeSession\n\n\nremote-helper: refactor push to use GitService\n\nprefactor to wire in GitService for push\n\n\nremote-helper: create initial command parser\n\nprefactor on the way to sans i/o the remote helper\n\n\nremote-helper: wire in command parser to main\n\n\nremote-helper: wire up BufRead and Writer for stdin and stdout",
"base": "50fb228ae4c0c8f68413dc7922a1e2a32ace5655",
"oid": "482641aada6acad92e42fe2f8bc7608bd443c5c7",
"timestamp": 1770827262
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "12528e3c-db31-4ce4-9a19-905145e5a366"
},
"info_url": "https://cci.rad.levitte.org//12528e3c-db31-4ce4-9a19-905145e5a366.html"
}
Started at: 2026-02-11 17:29:23.395451+01: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/12528e3c-db31-4ce4-9a19-905145e5a366/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 137 issues · 22 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 5d72ffd1d518e92de2f0193c0ddfd9901b7153df
✓ Switched to branch patch/5d72ffd at revision 5d72ffd
✓ Branch patch/5d72ffd setup to track rad/patches/5d72ffd1d518e92de2f0193c0ddfd9901b7153df
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 482641aada6acad92e42fe2f8bc7608bd443c5c7
HEAD is now at 482641aa remote-helper: wire up BufRead and Writer for stdin and stdout
Exit code: 0
$ rad patch show 5d72ffd1d518e92de2f0193c0ddfd9901b7153df -p
╭───────────────────────────────────────────────────────────────────────────────────────╮
│ Title remote-helper: prefactor to be more testable │
│ Patch 5d72ffd1d518e92de2f0193c0ddfd9901b7153df │
│ Author ade z6MkwGo…yS2aagA │
│ Head 482641aada6acad92e42fe2f8bc7608bd443c5c7 │
│ Base 50fb228ae4c0c8f68413dc7922a1e2a32ace5655 │
│ Branches patch/5d72ffd │
│ Commits ahead 9, behind 2 │
│ Status open │
│ │
│ To introduce new remote-helper changes like 'dont push by default' and │
│ 'self' / 'rad' remotes, this crate has been refactored to ease testing. │
│ │
│ - the command parsing has been refactored into a sans i/o esque typed parser. │
│ - the radicle node interactions have been hoisted into a `NodeSession` trait. │
│ - the git `send_pack` and `fetch_pack` interactions have hoisted into │
│ `GitService` trait. │
│ - the direct stdin/out printing has been replaced with `BufRead` and `Writer` │
│ traits. │
│ │
│ remote-helper: refactor for_fetch and patch_refs to return vectors of strings │
│ │
│ prefactor to aid in testing remote-helper. return vectors of strings │
│ which can eventually become entries in a buffer. │
│ │
│ │
│ remote-helper: create GitService trait │
│ │
│ prefactor to introduce a GitService trait around the fetch_pack and │
│ send_pack boundry. │
│ │
│ │
│ remote-helper: wire in the GitService │
│ │
│ │
│ remote-helper: create NodeSession trait and Real impl │
│ │
│ prefactor to add a trait over the Node boundary │
│ │
│ │
│ remote-helper: wire in NodeSession │
│ │
│ │
│ remote-helper: refactor push to use GitService │
│ │
│ prefactor to wire in GitService for push │
│ │
│ │
│ remote-helper: create initial command parser │
│ │
│ prefactor on the way to sans i/o the remote helper │
│ │
│ │
│ remote-helper: wire in command parser to main │
│ │
│ │
│ remote-helper: wire up BufRead and Writer for stdin and stdout │
├───────────────────────────────────────────────────────────────────────────────────────┤
│ 482641a remote-helper: wire up BufRead and Writer for stdin and stdout │
│ c3802df remote-helper: wire in command parser to main │
│ d67e014 remote-helper: create initial command parser │
│ d1d7a75 remote-helper: refactor push to use GitService │
│ 25d4a92 remote-helper: wire in NodeSession │
│ 8048df9 remote-helper: create NodeSession trait and Real impl │
│ 1663d29 remote-helper: wire in the GitService │
│ 1414f84 remote-helper: create GitService trait │
│ d6499a1 remote-helper: refactor for_fetch and patch_refs to return vectors of strings │
├───────────────────────────────────────────────────────────────────────────────────────┤
│ ● Revision 5d72ffd @ 482641a by ade z6MkwGo…yS2aagA 1 minute ago │
╰───────────────────────────────────────────────────────────────────────────────────────╯
commit 482641aada6acad92e42fe2f8bc7608bd443c5c7
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Tue Feb 10 13:43:24 2026 +0000
remote-helper: wire up BufRead and Writer for stdin and stdout
diff --git a/crates/radicle-remote-helper/src/fetch.rs b/crates/radicle-remote-helper/src/fetch.rs
index 596c65154..637cd9b02 100644
--- a/crates/radicle-remote-helper/src/fetch.rs
+++ b/crates/radicle-remote-helper/src/fetch.rs
@@ -35,15 +35,16 @@ pub enum Error {
/// Run a git fetch command.
pub fn run<G: GitService>(
mut refs: Vec<(git::Oid, git::fmt::RefString)>,
- stored: radicle::storage::git::Repository,
+ stored: &radicle::storage::git::Repository,
git: &G,
- stdin: &io::Stdin,
+ mut stdin: impl io::BufRead,
verbosity: Verbosity,
) -> Result<(), Error> {
// Read all the `fetch` lines.
let mut line = String::new();
loop {
- let tokens = read_line(stdin, &mut line)?;
+ let tokens = read_line(&mut stdin, &mut line)?;
+ let tokens: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
match tokens.as_slice() {
["fetch", oid, refstr] => {
let oid = git::Oid::from_str(oid)?;
@@ -73,7 +74,7 @@ pub fn run<G: GitService>(
// used in the working copy, this will always result in the object
// missing. This seems to only be an issue with `libgit2`/`git2`
// and not `git` itself.
- let output = git.fetch_pack(working, &stored, oids, verbosity.into())?;
+ let output = git.fetch_pack(working, stored, oids, verbosity.into())?;
if !output.status.success() {
return Err(Error::FetchPackFailed {
diff --git a/crates/radicle-remote-helper/src/main.rs b/crates/radicle-remote-helper/src/main.rs
index b9bd85293..d774ef67d 100644
--- a/crates/radicle-remote-helper/src/main.rs
+++ b/crates/radicle-remote-helper/src/main.rs
@@ -22,10 +22,11 @@ mod protocol;
mod push;
mod service;
+use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::process;
use std::str::FromStr;
-use std::{env, fmt, io};
+use std::{env, fmt};
use thiserror::Error;
@@ -245,9 +246,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
let debug = radicle::profile::env::debug();
let stdin = io::stdin();
- let mut line = String::new();
- let mut opts = Options::default();
- let mut expected_refs = Vec::new();
+ let stdout = io::stdout();
let git = service::RealGitService;
let mut node = service::RealNodeSession::new(&profile);
@@ -257,9 +256,37 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
}
}
+ run_loop(
+ stdin.lock(),
+ stdout.lock(),
+ &git,
+ &mut node,
+ &stored,
+ &profile,
+ remote,
+ url,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+fn run_loop<R: BufRead, W: Write, G: service::GitService, N: service::NodeSession>(
+ mut input: R,
+ mut output: W,
+ git: &G,
+ node: &mut N,
+ stored: &storage::git::Repository,
+ profile: &Profile,
+ remote: Option<git::fmt::RefString>,
+ url: Url,
+) -> Result<(), Error> {
+ let mut line = String::new();
+ let mut opts = Options::default();
+ let mut expected_refs = Vec::new();
+ let debug = radicle::profile::env::debug();
+
loop {
line.clear();
- let read = stdin.read_line(&mut line)?;
+ let read = input.read_line(&mut line)?;
if read == 0 {
break;
}
@@ -272,10 +299,10 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
match cmd {
Line::Valid(Command::Capabilities) => {
- println!("option");
- println!("push"); // Implies `list` command.
- println!("fetch");
- println!();
+ writeln!(output, "option")?;
+ writeln!(output, "push")?; // Implies `list` command.
+ writeln!(output, "fetch")?;
+ writeln!(output)?;
}
Line::Valid(Command::Option { key, value }) => match key.as_str() {
"verbosity" => {
@@ -283,14 +310,14 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
match val.parse::<Verbosity>() {
Ok(verbosity) => {
opts.verbosity = verbosity;
- println!("ok");
+ writeln!(output, "ok")?;
}
Err(err) => {
- println!("error {err}");
+ writeln!(output, "error {err}")?;
}
}
} else {
- println!("error missing value for verbosity");
+ writeln!(output, "error missing value for verbosity")?;
}
}
"push-option" => {
@@ -301,71 +328,71 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
// "push-option" itself is an unsupported option, which is not helpful or correct.
// Hence, we just exit with an error in this case.
push_option(&args, &mut opts)?;
- println!("ok");
+ writeln!(output, "ok")?;
} else {
- println!("error missing value for push-option");
+ writeln!(output, "error missing value for push-option")?;
}
}
"cas" => {
if let Some(val) = value {
expected_refs.push(val);
- println!("ok");
+ writeln!(output, "ok")?;
} else {
- println!("error missing value for cas");
+ writeln!(output, "error missing value for cas")?;
}
}
"progress" => {
- println!("unsupported");
+ writeln!(output, "unsupported")?;
}
_ => {
- println!("unsupported");
+ writeln!(output, "unsupported")?;
}
},
Line::Valid(Command::Fetch { oid, refstr }) => {
let oid = git::Oid::from_str(&oid)?;
let refstr = git::fmt::RefString::try_from(refstr.as_str())?;
- fetch::run(vec![(oid, refstr)], stored, &git, &stdin, opts.verbosity)?;
+ fetch::run(vec![(oid, refstr)], stored, git, &mut input, opts.verbosity)?;
// Nb. An empty line means we're done
- println!();
+ writeln!(output)?;
return Ok(());
}
Line::Valid(Command::Push(refspec)) => {
- let output = push::run(
+ let result = push::run(
vec![refspec],
- remote,
- url,
- &stored,
- &profile,
- &stdin,
- opts,
+ remote.clone(),
+ url.clone(),
+ stored,
+ profile,
+ &mut input,
+ opts.clone(),
&expected_refs,
- &git,
- &mut node,
+ git,
+ node,
)?;
- for line in output {
- println!("{line}");
+ for line in result {
+ writeln!(output, "{line}")?;
}
- println!();
+ writeln!(output)?;
return Ok(());
}
Line::Valid(Command::List) => {
- let refs = list::for_fetch(&url, &profile, &stored)?;
+ let refs = list::for_fetch(&url, profile, stored)?;
for line in refs {
- println!("{line}");
+ writeln!(output, "{line}")?;
}
- println!();
+ writeln!(output)?;
}
Line::Valid(Command::ListForPush) => {
- let refs = list::for_push(&profile, &stored)?;
+ let refs = list::for_push(profile, stored)?;
for line in refs {
- println!("{line}");
+ writeln!(output, "{line}")?;
}
- println!();
+ writeln!(output)?;
}
Line::Blank => {
return Ok(());
@@ -467,3 +494,12 @@ pub(crate) fn patches_mut<'a>(
Err(err) => Err(err.into()),
}
}
+
+pub fn read_line(r: &mut impl io::BufRead, line: &mut String) -> Result<Vec<String>, io::Error> {
+ line.clear();
+ let read = r.read_line(line)?;
+ if read == 0 {
+ return Ok(Vec::new());
+ }
+ Ok(line.split_whitespace().map(|s| s.to_string()).collect())
+}
diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs
index 35af6ce7a..a658c693e 100644
--- a/crates/radicle-remote-helper/src/push.rs
+++ b/crates/radicle-remote-helper/src/push.rs
@@ -27,7 +27,6 @@ use radicle::storage::git::transport::local::Url;
use radicle::storage::{ReadRepository, SignRepository as _, WriteRepository};
use radicle::Profile;
use radicle::{git, rad};
-use radicle_cli as cli;
use radicle_cli::terminal as term;
use crate::service::{GitService, NodeSession};
@@ -249,7 +248,7 @@ pub fn run(
url: Url,
stored: &storage::git::Repository,
profile: &Profile,
- stdin: &io::Stdin,
+ mut stdin: impl io::BufRead,
opts: Options,
expected_refs: &[String],
git: &impl GitService,
@@ -276,9 +275,10 @@ pub fn run(
// Read all the `push` lines.
loop {
- let tokens = read_line(stdin, &mut line)?;
+ let tokens = read_line(&mut stdin, &mut line)?;
+ let tokens: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
match tokens.as_slice() {
- ["push", spec] => {
+ [cmd, spec] if *cmd == "push" => {
specs.push(spec.to_string());
}
// An empty line means end of input.
commit c3802dfae2b9c5728bb0054688b2280eff813bf5
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Mon Feb 9 23:27:08 2026 +0000
remote-helper: wire in command parser to main
diff --git a/crates/radicle-remote-helper/src/main.rs b/crates/radicle-remote-helper/src/main.rs
index c45f4393e..b9bd85293 100644
--- a/crates/radicle-remote-helper/src/main.rs
+++ b/crates/radicle-remote-helper/src/main.rs
@@ -18,6 +18,7 @@
mod fetch;
mod list;
+mod protocol;
mod push;
mod service;
@@ -36,6 +37,8 @@ use radicle::{cob, profile};
use radicle::{git, storage, Profile};
use radicle_cli::terminal as cli;
+use crate::protocol::{Command, Line};
+
pub const VERSION: Version = Version {
name: env!("CARGO_BIN_NAME"),
commit: env!("GIT_HEAD"),
@@ -121,6 +124,9 @@ pub enum Error {
/// Invalid object ID.
#[error("invalid oid: {0}")]
InvalidOid(#[from] radicle::git::ParseOidError),
+ /// Protocol error.
+ #[error(transparent)]
+ Protocol(#[from] protocol::Error),
}
/// Models values for the `verbosity` option, see
@@ -252,46 +258,72 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
}
loop {
- let tokens = read_line(&stdin, &mut line)?;
+ line.clear();
+ let read = stdin.read_line(&mut line)?;
+ if read == 0 {
+ break;
+ }
+
+ let cmd = Command::parse_line(&line)?;
if debug {
- eprintln!("{}: {}", VERSION.name, &tokens.join(" "));
+ eprintln!("{}: {:?}", VERSION.name, cmd);
}
- match tokens.as_slice() {
- ["capabilities"] => {
+ match cmd {
+ Line::Valid(Command::Capabilities) => {
println!("option");
println!("push"); // Implies `list` command.
println!("fetch");
println!();
}
- ["option", "verbosity", verbosity] => match verbosity.parse::<Verbosity>() {
- Ok(verbosity) => {
- opts.verbosity = verbosity;
- println!("ok");
+ Line::Valid(Command::Option { key, value }) => match key.as_str() {
+ "verbosity" => {
+ if let Some(val) = value {
+ match val.parse::<Verbosity>() {
+ Ok(verbosity) => {
+ opts.verbosity = verbosity;
+ println!("ok");
+ }
+ Err(err) => {
+ println!("error {err}");
+ }
+ }
+ } else {
+ println!("error missing value for verbosity");
+ }
+ }
+ "push-option" => {
+ if let Some(val) = value {
+ let args = val.split(' ').collect::<Vec<_>>();
+ // Nb. Git documentation says that we can print `error <msg>` or `unsupported`
+ // for options that are not supported, but this results in Git saying that
+ // "push-option" itself is an unsupported option, which is not helpful or correct.
+ // Hence, we just exit with an error in this case.
+ push_option(&args, &mut opts)?;
+ println!("ok");
+ } else {
+ println!("error missing value for push-option");
+ }
}
- Err(err) => {
- println!("error {err}");
+ "cas" => {
+ if let Some(val) = value {
+ expected_refs.push(val);
+ println!("ok");
+ } else {
+ println!("error missing value for cas");
+ }
+ }
+ "progress" => {
+ println!("unsupported");
+ }
+ _ => {
+ println!("unsupported");
}
},
- ["option", "push-option", args @ ..] => {
- // Nb. Git documentation says that we can print `error <msg>` or `unsupported`
- // for options that are not supported, but this results in Git saying that
- // "push-option" itself is an unsupported option, which is not helpful or correct.
- // Hence, we just exit with an error in this case.
- push_option(args, &mut opts)?;
- println!("ok");
- }
- ["option", "cas", refstr] => {
- expected_refs.push((*refstr).to_owned());
- println!("ok");
- }
- ["option", "progress", ..] | ["option", ..] => {
- println!("unsupported");
- }
- ["fetch", oid, refstr] => {
- let oid = git::Oid::from_str(oid)?;
- let refstr = git::fmt::RefString::try_from(*refstr)?;
+ Line::Valid(Command::Fetch { oid, refstr }) => {
+ let oid = git::Oid::from_str(&oid)?;
+ let refstr = git::fmt::RefString::try_from(refstr.as_str())?;
fetch::run(vec![(oid, refstr)], stored, &git, &stdin, opts.verbosity)?;
@@ -300,9 +332,9 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
return Ok(());
}
- ["push", refspec] => {
+ Line::Valid(Command::Push(refspec)) => {
let output = push::run(
- vec![refspec.to_string()],
+ vec![refspec],
remote,
url,
&stored,
@@ -321,28 +353,27 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
return Ok(());
}
- ["list"] => {
+ Line::Valid(Command::List) => {
let refs = list::for_fetch(&url, &profile, &stored)?;
for line in refs {
println!("{line}");
}
println!();
}
- ["list", "for-push"] => {
+ Line::Valid(Command::ListForPush) => {
let refs = list::for_push(&profile, &stored)?;
for line in refs {
println!("{line}");
}
println!();
}
- [] => {
+ Line::Blank => {
return Ok(());
}
- _ => {
- return Err(Error::InvalidCommand(line.trim().to_owned()));
- }
}
}
+
+ Ok(())
}
/// Parse a single push option. Returns `Ok` if it was successful.
@@ -394,20 +425,6 @@ fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> {
Ok(())
}
-/// Read one line from stdin, and split it into tokens.
-pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> {
- line.clear();
-
- let read = stdin.read_line(line)?;
- if read == 0 {
- return Ok(vec![]);
- }
- let line = line.trim();
- let tokens = line.split(' ').filter(|t| !t.is_empty()).collect();
-
- Ok(tokens)
-}
-
/// Write a hint to the user.
pub(crate) fn hint(s: impl fmt::Display) {
eprintln!("{}", cli::format::hint(format!("hint: {s}")));
commit d67e01474cd7d78307570a2ca1f95454e2dd62e6
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Mon Feb 9 22:33:51 2026 +0000
remote-helper: create initial command parser
prefactor on the way to sans i/o the remote helper
diff --git a/crates/radicle-remote-helper/src/protocol.rs b/crates/radicle-remote-helper/src/protocol.rs
new file mode 100644
index 000000000..e98d09500
--- /dev/null
+++ b/crates/radicle-remote-helper/src/protocol.rs
@@ -0,0 +1,190 @@
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum Error {
+ #[error("invalid command `{0}`")]
+ InvalidCommand(String),
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Command {
+ Capabilities,
+ List,
+ ListForPush,
+ Fetch { oid: String, refstr: String },
+ Push(String),
+ Option { key: String, value: Option<String> },
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Line {
+ Valid(Command),
+ Blank,
+}
+
+impl Command {
+ pub fn parse_line(line: &str) -> Result<Line, Error> {
+ let line = line.trim();
+ if line.is_empty() {
+ return Ok(Line::Blank);
+ }
+
+ // Split the command verb from the rest of the line.
+ let (cmd, args) = line.split_once(' ').unwrap_or((line, ""));
+ let args = args.trim();
+
+ match cmd {
+ "capabilities" => Ok(Line::Valid(Command::Capabilities)),
+ "list" => {
+ if args == "for-push" {
+ Ok(Line::Valid(Command::ListForPush))
+ } else if args.is_empty() {
+ Ok(Line::Valid(Command::List))
+ } else {
+ Err(Error::InvalidCommand(line.to_owned()))
+ }
+ }
+ "fetch" => {
+ // fetch <oid> <name>
+ // Use split_whitespace to handle multiple spaces between OID and Ref,
+ // which is permitted.
+ let mut parts = args.split_whitespace();
+ let oid = parts
+ .next()
+ .ok_or_else(|| Error::InvalidCommand(line.to_owned()))?;
+ let refstr = parts
+ .next()
+ .ok_or_else(|| Error::InvalidCommand(line.to_owned()))?;
+ Ok(Line::Valid(Command::Fetch {
+ oid: oid.to_owned(),
+ refstr: refstr.to_owned(),
+ }))
+ }
+ "push" => Ok(Line::Valid(Command::Push(args.to_owned()))),
+ "option" => {
+ // option <key> [value]
+ // Use split_once to preserve whitespace in the value.
+ let (key, val) = args.split_once(' ').unwrap_or((args, ""));
+ let value = if val.is_empty() {
+ None
+ } else {
+ Some(val.to_owned())
+ };
+ Ok(Line::Valid(Command::Option {
+ key: key.to_owned(),
+ value,
+ }))
+ }
+ _ => Err(Error::InvalidCommand(line.to_owned())),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_capabilities() {
+ assert_eq!(
+ Command::parse_line("capabilities").unwrap(),
+ Line::Valid(Command::Capabilities)
+ );
+ }
+
+ #[test]
+ fn test_list() {
+ assert_eq!(
+ Command::parse_line("list").unwrap(),
+ Line::Valid(Command::List)
+ );
+ }
+
+ #[test]
+ fn test_list_for_push() {
+ assert_eq!(
+ Command::parse_line("list for-push").unwrap(),
+ Line::Valid(Command::ListForPush)
+ );
+ }
+
+ #[test]
+ fn test_fetch() {
+ assert_eq!(
+ Command::parse_line("fetch oid ref").unwrap(),
+ Line::Valid(Command::Fetch {
+ oid: "oid".to_owned(),
+ refstr: "ref".to_owned()
+ })
+ );
+ }
+
+ #[test]
+ fn test_fetch_whitespace() {
+ assert_eq!(
+ Command::parse_line("fetch oid ref").unwrap(),
+ Line::Valid(Command::Fetch {
+ oid: "oid".to_owned(),
+ refstr: "ref".to_owned()
+ })
+ );
+ }
+
+ #[test]
+ fn test_push() {
+ assert_eq!(
+ Command::parse_line("push src:dst").unwrap(),
+ Line::Valid(Command::Push("src:dst".to_owned()))
+ );
+ }
+
+ #[test]
+ fn test_push_force() {
+ assert_eq!(
+ Command::parse_line("push +src:dst").unwrap(),
+ Line::Valid(Command::Push("+src:dst".to_owned()))
+ );
+ }
+
+ #[test]
+ fn test_push_delete() {
+ assert_eq!(
+ Command::parse_line("push :dst").unwrap(),
+ Line::Valid(Command::Push(":dst".to_owned()))
+ );
+ }
+
+ #[test]
+ fn test_option() {
+ assert_eq!(
+ Command::parse_line("option verbosity 2").unwrap(),
+ Line::Valid(Command::Option {
+ key: "verbosity".to_owned(),
+ value: Some("2".to_owned())
+ })
+ );
+ }
+
+ #[test]
+ fn test_option_whitespace_preservation() {
+ assert_eq!(
+ Command::parse_line("option patch.message Fix: whitespace").unwrap(),
+ Line::Valid(Command::Option {
+ key: "patch.message".to_owned(),
+ value: Some("Fix: whitespace".to_owned())
+ })
+ );
+ }
+
+ #[test]
+ fn test_empty() {
+ assert_eq!(Command::parse_line("").unwrap(), Line::Blank);
+ assert_eq!(Command::parse_line(" ").unwrap(), Line::Blank);
+ }
+
+ #[test]
+ fn test_invalid() {
+ assert!(Command::parse_line("invalid command").is_err());
+ assert!(Command::parse_line("list invalid").is_err());
+ }
+}
commit d1d7a751d024be66f2d4f20c1012687937eadc9d
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Mon Feb 9 23:16:58 2026 +0000
remote-helper: refactor push to use GitService
prefactor to wire in GitService for push
diff --git a/crates/radicle-remote-helper/src/main.rs b/crates/radicle-remote-helper/src/main.rs
index 51c1704aa..c45f4393e 100644
--- a/crates/radicle-remote-helper/src/main.rs
+++ b/crates/radicle-remote-helper/src/main.rs
@@ -301,7 +301,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
return Ok(());
}
["push", refspec] => {
- return Ok(push::run(
+ let output = push::run(
vec![refspec.to_string()],
remote,
url,
@@ -310,8 +310,16 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
&stdin,
opts,
&expected_refs,
+ &git,
&mut node,
- )?);
+ )?;
+
+ for line in output {
+ println!("{line}");
+ }
+ println!();
+
+ return Ok(());
}
["list"] => {
let refs = list::for_fetch(&url, &profile, &stored)?;
diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs
index 07024da03..35af6ce7a 100644
--- a/crates/radicle-remote-helper/src/push.rs
+++ b/crates/radicle-remote-helper/src/push.rs
@@ -30,7 +30,7 @@ use radicle::{git, rad};
use radicle_cli as cli;
use radicle_cli::terminal as term;
-use crate::service::NodeSession;
+use crate::service::{GitService, NodeSession};
use crate::{hint, read_line, warn, Options, Verbosity};
#[derive(Debug, Error)]
@@ -252,8 +252,9 @@ pub fn run(
stdin: &io::Stdin,
opts: Options,
expected_refs: &[String],
+ git: &impl GitService,
node: &mut impl NodeSession,
-) -> Result<(), Error> {
+) -> Result<Vec<String>, Error> {
// Don't allow push if either of these conditions is true:
//
// 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
@@ -269,6 +270,7 @@ pub fn run(
let mut line = String::new();
let mut ok = HashMap::new();
let hints = opts.hints || profile.hints();
+ let mut output = Vec::new();
assert_eq!(signer.public_key(), &nid);
@@ -331,6 +333,7 @@ pub fn run(
&signer,
profile,
opts.clone(),
+ git,
),
PushAction::UpdatePatch { dst, patch } => patch_update(
src,
@@ -344,6 +347,7 @@ pub fn run(
&signer,
opts.clone(),
expected_refs,
+ git,
),
PushAction::PushRef { dst } => {
let identity = stored.identity()?;
@@ -365,6 +369,7 @@ pub fn run(
&signer,
opts.verbosity,
expected_refs,
+ git,
)?;
// If we're trying to update the canonical head, make sure
// we don't diverge from the current head. This only applies
@@ -393,11 +398,11 @@ pub fn run(
match result {
// Let Git tooling know that this ref has been pushed.
Ok(resource) => {
- println!("ok {}", cmd.dst());
+ output.push(format!("ok {}", cmd.dst()));
ok.insert(spec, resource);
}
// Let Git tooling know that there was an error pushing the ref.
- Err(e) => println!("error {} {e}", cmd.dst()),
+ Err(e) => output.push(format!("error {} {e}", cmd.dst())),
}
}
@@ -476,10 +481,7 @@ pub fn run(
}
}
- // Done.
- println!();
-
- Ok(())
+ Ok(output)
}
fn patch_base(
@@ -509,15 +511,25 @@ fn patch_base(
///
/// We choose to push a temporary reference to storage, which gets deleted on
/// [`Drop::drop`].
-struct TempPatchRef<'a> {
+struct TempPatchRef<'a, G> {
stored: &'a storage::git::Repository,
reference: git::fmt::Namespaced<'a>,
+ git: &'a G,
}
-impl<'a> TempPatchRef<'a> {
- fn new(stored: &'a storage::git::Repository, head: &git::Oid, nid: &NodeId) -> Self {
+impl<'a, G: GitService> TempPatchRef<'a, G> {
+ fn new(
+ stored: &'a storage::git::Repository,
+ head: &git::Oid,
+ nid: &NodeId,
+ git: &'a G,
+ ) -> Self {
let reference = git::refs::storage::staging::patch(nid, *head);
- Self { stored, reference }
+ Self {
+ stored,
+ reference,
+ git,
+ }
}
fn push(&self, src: &git::Oid, verbosity: Verbosity) -> Result<(), Error> {
@@ -528,11 +540,12 @@ impl<'a> TempPatchRef<'a> {
self.stored.raw(),
verbosity,
&[],
+ self.git,
)
}
}
-impl<'a> Drop for TempPatchRef<'a> {
+impl<'a, G> Drop for TempPatchRef<'a, G> {
fn drop(&mut self) {
if let Err(err) = self
.stored
@@ -550,7 +563,7 @@ impl<'a> Drop for TempPatchRef<'a> {
}
/// Open a new patch.
-fn patch_open<G>(
+fn patch_open<G, S>(
head: &git::Oid,
upstream: &Option<git::fmt::RefString>,
nid: &NodeId,
@@ -563,11 +576,13 @@ fn patch_open<G>(
signer: &Device<G>,
profile: &Profile,
opts: Options,
+ git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
+ S: GitService,
{
- let temp = TempPatchRef::new(stored, head, nid);
+ let temp = TempPatchRef::new(stored, head, nid, git);
temp.push(head, opts.verbosity)?;
let base = patch_base(head, &opts, stored)?;
@@ -686,7 +701,7 @@ where
/// Update an existing patch.
#[allow(clippy::too_many_arguments)]
-fn patch_update<G>(
+fn patch_update<G, S>(
head: &git::Oid,
dst: &git::fmt::Qualified,
force: bool,
@@ -701,15 +716,17 @@ fn patch_update<G>(
signer: &Device<G>,
opts: Options,
expected_refs: &[String],
+ git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
+ S: GitService,
{
let Ok(Some(patch)) = patches.get(&patch_id) else {
return Err(Error::NotFound(patch_id));
};
- let temp = TempPatchRef::new(stored, head, nid);
+ let temp = TempPatchRef::new(stored, head, nid, git);
temp.push(head, opts.verbosity)?;
let base = patch_base(head, &opts, stored)?;
@@ -736,6 +753,7 @@ where
stored.raw(),
opts.verbosity,
expected_refs,
+ git,
)?;
let mut patch_mut = patch::PatchMut::new(patch_id, patch, &mut patches);
@@ -772,7 +790,7 @@ where
Ok(Some(ExplorerResource::Patch { id: patch_id }))
}
-fn push<G>(
+fn push<G, S>(
src: &git::Oid,
dst: &git::fmt::Qualified,
force: bool,
@@ -786,16 +804,26 @@ fn push<G>(
signer: &Device<G>,
verbosity: Verbosity,
expected_refs: &[String],
+ git: &S,
) -> Result<Option<ExplorerResource>, Error>
where
G: crypto::signature::Signer<crypto::Signature>,
+ S: GitService,
{
let head = *src;
let dst = dst.with_namespace(nid.into());
// It's ok for the destination reference to be unknown, eg. when pushing a new branch.
let old = stored.backend.find_reference(dst.as_str()).ok();
- push_ref(src, &dst, force, stored.raw(), verbosity, expected_refs)?;
+ push_ref(
+ src,
+ &dst,
+ force,
+ stored.raw(),
+ verbosity,
+ expected_refs,
+ git,
+ )?;
if let Some(old) = old {
let proj = stored.project()?;
@@ -979,6 +1007,7 @@ fn push_ref(
stored: &git::raw::Repository,
verbosity: Verbosity,
expected_refs: &[String],
+ git: &impl GitService,
) -> Result<(), Error> {
let path = dunce::canonicalize(stored.path())?.display().to_string();
// Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code.
@@ -1002,7 +1031,7 @@ fn push_ref(
// Rely on the environment variable `GIT_DIR`.
let working = None;
- let output = radicle::git::run(working, args)?;
+ let output = git.send_pack(working, &args)?;
if !output.status.success() {
return Err(Error::SendPackFailed {
commit 25d4a92d3138131860cda8ed44d06495affab71b
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Mon Feb 9 23:09:58 2026 +0000
remote-helper: wire in NodeSession
diff --git a/crates/radicle-remote-helper/src/main.rs b/crates/radicle-remote-helper/src/main.rs
index 1174194a0..51c1704aa 100644
--- a/crates/radicle-remote-helper/src/main.rs
+++ b/crates/radicle-remote-helper/src/main.rs
@@ -243,6 +243,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
let mut opts = Options::default();
let mut expected_refs = Vec::new();
let git = service::RealGitService;
+ let mut node = service::RealNodeSession::new(&profile);
if let Err(e) = radicle::io::set_file_limit(4096) {
if debug {
@@ -309,6 +310,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
&stdin,
opts,
&expected_refs,
+ &mut node,
)?);
}
["list"] => {
diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs
index 7de690f20..07024da03 100644
--- a/crates/radicle-remote-helper/src/push.rs
+++ b/crates/radicle-remote-helper/src/push.rs
@@ -4,7 +4,6 @@ mod canonical;
mod error;
use std::collections::HashMap;
-use std::io::IsTerminal;
use std::process::ExitStatus;
use std::str::FromStr;
use std::{assert_eq, io};
@@ -22,7 +21,7 @@ use radicle::crypto;
use radicle::explorer::ExplorerResource;
use radicle::identity::{CanonicalRefs, Did};
use radicle::node;
-use radicle::node::{Handle, NodeId};
+use radicle::node::NodeId;
use radicle::storage;
use radicle::storage::git::transport::local::Url;
use radicle::storage::{ReadRepository, SignRepository as _, WriteRepository};
@@ -31,6 +30,7 @@ use radicle::{git, rad};
use radicle_cli as cli;
use radicle_cli::terminal as term;
+use crate::service::NodeSession;
use crate::{hint, read_line, warn, Options, Verbosity};
#[derive(Debug, Error)]
@@ -252,6 +252,7 @@ pub fn run(
stdin: &io::Stdin,
opts: Options,
expected_refs: &[String],
+ node: &mut impl NodeSession,
) -> Result<(), Error> {
// Don't allow push if either of these conditions is true:
//
@@ -456,10 +457,15 @@ pub fn run(
// Connect to local node and announce refs to the network.
// If our node is not running, we simply skip this step, as the
// refs will be announced eventually, when the node restarts.
- let node = radicle::Node::new(profile.socket());
if node.is_running() {
// Nb. allow this to fail. The push to local storage was still successful.
- sync(stored, ok.into_values().flatten(), opts, node, profile).ok();
+ node.sync(
+ stored,
+ ok.into_values().flatten().collect(),
+ opts,
+ profile,
+ )
+ .ok();
} else if hints {
hint("offline push, your node is not running");
hint("to sync with the network, run `rad node start`");
@@ -1008,59 +1014,3 @@ fn push_ref(
Ok(())
}
-
-/// Sync with the network.
-fn sync(
- repo: &storage::git::Repository,
- updated: impl Iterator<Item = ExplorerResource>,
- opts: Options,
- mut node: radicle::Node,
- profile: &Profile,
-) -> Result<(), cli::node::SyncError> {
- let progress = if io::stderr().is_terminal() {
- term::PaintTarget::Stderr
- } else {
- term::PaintTarget::Hidden
- };
- let result = cli::node::announce(
- repo,
- cli::node::SyncSettings::default().with_profile(profile),
- cli::node::SyncReporting {
- progress,
- completion: term::PaintTarget::Stderr,
- debug: opts.sync_debug,
- },
- &mut node,
- profile,
- )?;
-
- let mut urls = Vec::new();
-
- if let Some(result) = result {
- for seed in profile.config.preferred_seeds.iter() {
- if result.is_synced(&seed.id) {
- for resource in updated {
- let url = profile
- .config
- .public_explorer
- .url(seed.addr.host.clone(), repo.id)
- .resource(resource);
-
- urls.push(url);
- }
- break;
- }
- }
- }
-
- // Print URLs to the updated resources.
- if !urls.is_empty() {
- eprintln!();
- for url in urls {
- eprintln!(" {}", term::format::dim(url));
- }
- eprintln!();
- }
-
- Ok(())
-}
commit 8048df940746dfce01f406c80d01a3e1df3f7ac6
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Mon Feb 9 23:09:02 2026 +0000
remote-helper: create NodeSession trait and Real impl
prefactor to add a trait over the Node boundary
diff --git a/crates/radicle-remote-helper/src/service.rs b/crates/radicle-remote-helper/src/service.rs
index 805bb0ed7..e1b63af26 100644
--- a/crates/radicle-remote-helper/src/service.rs
+++ b/crates/radicle-remote-helper/src/service.rs
@@ -1,9 +1,15 @@
use std::io;
+use std::io::IsTerminal;
use std::path::Path;
use std::process;
+use radicle::explorer::ExplorerResource;
use radicle::git;
+use radicle::node::Handle;
use radicle::storage;
+use radicle::Profile;
+use radicle_cli::node::{SyncError, SyncReporting, SyncSettings};
+use radicle_cli::terminal as term;
/// Abstraction for Git subprocess calls.
pub trait GitService {
@@ -46,3 +52,90 @@ impl GitService for RealGitService {
git::run(working, args)
}
}
+
+/// Abstraction for Node interaction.
+pub trait NodeSession {
+ fn is_running(&self) -> bool;
+
+ fn sync(
+ &mut self,
+ repo: &storage::git::Repository,
+ updated: Vec<ExplorerResource>,
+ opts: crate::Options,
+ profile: &Profile,
+ ) -> Result<(), SyncError>;
+}
+
+pub struct RealNodeSession {
+ node: radicle::Node,
+}
+
+impl RealNodeSession {
+ pub fn new(profile: &Profile) -> Self {
+ Self {
+ node: radicle::Node::new(profile.socket()),
+ }
+ }
+}
+
+impl NodeSession for RealNodeSession {
+ fn is_running(&self) -> bool {
+ self.node.is_running()
+ }
+
+ fn sync(
+ &mut self,
+ repo: &storage::git::Repository,
+ updated: Vec<ExplorerResource>,
+ opts: crate::Options,
+ profile: &Profile,
+ ) -> Result<(), SyncError> {
+ let progress = if io::stderr().is_terminal() {
+ term::PaintTarget::Stderr
+ } else {
+ term::PaintTarget::Hidden
+ };
+
+ let result = radicle_cli::node::announce(
+ repo,
+ SyncSettings::default().with_profile(profile),
+ SyncReporting {
+ progress,
+ completion: term::PaintTarget::Stderr,
+ debug: opts.sync_debug,
+ },
+ &mut self.node,
+ profile,
+ )?;
+
+ let mut urls = Vec::new();
+
+ if let Some(result) = result {
+ for seed in profile.config.preferred_seeds.iter() {
+ if result.is_synced(&seed.id) {
+ for resource in updated {
+ let url = profile
+ .config
+ .public_explorer
+ .url(seed.addr.host.clone(), repo.id)
+ .resource(resource);
+
+ urls.push(url);
+ }
+ break;
+ }
+ }
+ }
+
+ // Print URLs to the updated resources.
+ if !urls.is_empty() {
+ eprintln!();
+ for url in urls {
+ eprintln!(" {}", term::format::dim(url));
+ }
+ eprintln!();
+ }
+
+ Ok(())
+ }
+}
commit 1663d29e1f1383c0cbbd1d276a0c20469c119fa2
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Mon Feb 9 22:57:27 2026 +0000
remote-helper: wire in the GitService
diff --git a/crates/radicle-remote-helper/src/fetch.rs b/crates/radicle-remote-helper/src/fetch.rs
index 1c3829dc7..596c65154 100644
--- a/crates/radicle-remote-helper/src/fetch.rs
+++ b/crates/radicle-remote-helper/src/fetch.rs
@@ -4,8 +4,8 @@ use std::{io, process::ExitStatus};
use thiserror::Error;
use radicle::git;
-use radicle::storage::ReadRepository;
+use crate::service::GitService;
use crate::{read_line, Verbosity};
#[derive(Debug, Error)]
@@ -33,9 +33,10 @@ pub enum Error {
}
/// Run a git fetch command.
-pub fn run<R: ReadRepository>(
+pub fn run<G: GitService>(
mut refs: Vec<(git::Oid, git::fmt::RefString)>,
- stored: R,
+ stored: radicle::storage::git::Repository,
+ git: &G,
stdin: &io::Stdin,
verbosity: Verbosity,
) -> Result<(), Error> {
@@ -58,7 +59,7 @@ pub fn run<R: ReadRepository>(
}
// Verify them and prepare the final refspecs.
- let oids = refs.into_iter().map(|(oid, _)| oid);
+ let oids = refs.into_iter().map(|(oid, _)| oid).collect();
// Rely on the environment variable `GIT_DIR` pointing at the repository.
let working = None;
@@ -72,7 +73,7 @@ pub fn run<R: ReadRepository>(
// used in the working copy, this will always result in the object
// missing. This seems to only be an issue with `libgit2`/`git2`
// and not `git` itself.
- let output = git::process::fetch_pack(working, &stored, oids, verbosity.into())?;
+ let output = git.fetch_pack(working, &stored, oids, verbosity.into())?;
if !output.status.success() {
return Err(Error::FetchPackFailed {
@@ -82,8 +83,5 @@ pub fn run<R: ReadRepository>(
});
}
- // Nb. An empty line means we're done.
- println!();
-
Ok(())
}
diff --git a/crates/radicle-remote-helper/src/main.rs b/crates/radicle-remote-helper/src/main.rs
index 860c56aec..1174194a0 100644
--- a/crates/radicle-remote-helper/src/main.rs
+++ b/crates/radicle-remote-helper/src/main.rs
@@ -19,6 +19,7 @@
mod fetch;
mod list;
mod push;
+mod service;
use std::path::PathBuf;
use std::process;
@@ -241,6 +242,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
let mut line = String::new();
let mut opts = Options::default();
let mut expected_refs = Vec::new();
+ let git = service::RealGitService;
if let Err(e) = radicle::io::set_file_limit(4096) {
if debug {
@@ -290,12 +292,12 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
let oid = git::Oid::from_str(oid)?;
let refstr = git::fmt::RefString::try_from(*refstr)?;
- return Ok(fetch::run(
- vec![(oid, refstr)],
- stored,
- &stdin,
- opts.verbosity,
- )?);
+ fetch::run(vec![(oid, refstr)], stored, &git, &stdin, opts.verbosity)?;
+
+ // Nb. An empty line means we're done
+ println!();
+
+ return Ok(());
}
["push", refspec] => {
return Ok(push::run(
commit 1414f84ff9644624ea7caf2c0ce150de1e84f5fd
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Mon Feb 9 22:50:09 2026 +0000
remote-helper: create GitService trait
prefactor to introduce a GitService trait around the fetch_pack and
send_pack boundry.
diff --git a/crates/radicle-remote-helper/src/service.rs b/crates/radicle-remote-helper/src/service.rs
new file mode 100644
index 000000000..805bb0ed7
--- /dev/null
+++ b/crates/radicle-remote-helper/src/service.rs
@@ -0,0 +1,48 @@
+use std::io;
+use std::path::Path;
+use std::process;
+
+use radicle::git;
+use radicle::storage;
+
+/// Abstraction for Git subprocess calls.
+pub trait GitService {
+ /// Run `git fetch-pack`.
+ fn fetch_pack(
+ &self,
+ working: Option<&Path>,
+ stored: &storage::git::Repository,
+ oids: Vec<git::Oid>,
+ verbosity: git::Verbosity,
+ ) -> io::Result<process::Output>;
+
+ /// Run `git send-pack` (via `radicle::git::run`).
+ fn send_pack(
+ &self,
+ working: Option<&Path>,
+ args: &[String],
+ ) -> io::Result<process::Output>;
+}
+
+/// Production implementation using real Git subprocesses.
+pub struct RealGitService;
+
+impl GitService for RealGitService {
+ fn fetch_pack(
+ &self,
+ working: Option<&Path>,
+ stored: &storage::git::Repository,
+ oids: Vec<git::Oid>,
+ verbosity: git::Verbosity,
+ ) -> io::Result<process::Output> {
+ git::process::fetch_pack(working, stored, oids, verbosity)
+ }
+
+ fn send_pack(
+ &self,
+ working: Option<&Path>,
+ args: &[String],
+ ) -> io::Result<process::Output> {
+ git::run(working, args)
+ }
+}
commit d6499a1273c9ab266a3ff1e86030d6a53c790bf4
Author: Adrian Duke <adrian.duke@gmail.com>
Date: Mon Feb 9 22:48:53 2026 +0000
remote-helper: refactor for_fetch and patch_refs to return vectors of strings
prefactor to aid in testing remote-helper. return vectors of strings
which can eventually become entries in a buffer.
diff --git a/crates/radicle-remote-helper/src/list.rs b/crates/radicle-remote-helper/src/list.rs
index 3a34698c0..43d74a12a 100644
--- a/crates/radicle-remote-helper/src/list.rs
+++ b/crates/radicle-remote-helper/src/list.rs
@@ -39,17 +39,19 @@ pub fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
url: &Url,
profile: &Profile,
stored: &R,
-) -> Result<(), Error> {
+) -> Result<Vec<String>, Error> {
+ let mut lines = Vec::new();
+
if let Some(namespace) = url.namespace {
// Listing namespaced refs.
for (name, oid) in stored.references_of(&namespace)? {
- println!("{oid} {name}");
+ lines.push(format!("{oid} {name}"));
}
} else {
// List the symbolic reference `HEAD`, which is interpreted by
// Git clients to determine the default branch.
match stored.head() {
- Ok((target, _)) => println!("@{target} HEAD"),
+ Ok((target, _)) => lines.push(format!("@{target} HEAD")),
Err(err) => eprintln!("remote: error resolving HEAD: {err}"),
}
@@ -60,43 +62,45 @@ pub fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
git::fmt::pattern!("refs/tags/*"),
] {
for (name, oid) in stored.references_glob(&glob)? {
- println!("{oid} {name}");
+ lines.push(format!("{oid} {name}"));
}
}
// List the patch refs, but do not abort if there is an error,
// as this would break all fetch behavior.
// Instead, just output an error to the user.
- if let Err(e) = patch_refs(profile, stored) {
- eprintln!("remote: error listing patch refs: {e}");
+ match patch_refs(profile, stored) {
+ Ok(mut refs) => lines.append(&mut refs),
+ Err(e) => eprintln!("remote: error listing patch refs: {e}"),
}
}
- println!();
- Ok(())
+ Ok(lines)
}
/// List refs for pushing (`git push`).
-pub fn for_push<R: ReadRepository>(profile: &Profile, stored: &R) -> Result<(), Error> {
+pub fn for_push<R: ReadRepository>(profile: &Profile, stored: &R) -> Result<Vec<String>, Error> {
+ let mut lines = Vec::new();
+
// Only our own refs can be pushed to.
for (name, oid) in stored.references_of(profile.id())? {
// Only branches and tags can be pushed to.
if name.starts_with(git::fmt::refname!("refs/heads").as_str())
|| name.starts_with(git::fmt::refname!("refs/tags").as_str())
{
- println!("{oid} {name}");
+ lines.push(format!("{oid} {name}"));
}
}
- println!();
- Ok(())
+ Ok(lines)
}
/// List canonical patch references. These are magic refs that can be used to pull patch updates.
fn patch_refs<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
profile: &Profile,
stored: &R,
-) -> Result<(), Error> {
+) -> Result<Vec<String>, Error> {
+ let mut lines = Vec::new();
let patches = crate::patches(profile, stored)?;
for patch in patches.list()? {
let Ok((id, patch)) = patch else {
@@ -106,8 +110,8 @@ fn patch_refs<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
let head = patch.head();
if patch.is_open() && stored.commit(*head).is_ok() {
- println!("{} {}", patch.head(), git::refs::patch(&id));
+ lines.push(format!("{} {}", patch.head(), git::refs::patch(&id)));
}
}
- Ok(())
+ Ok(lines)
}
diff --git a/crates/radicle-remote-helper/src/main.rs b/crates/radicle-remote-helper/src/main.rs
index 525b5255e..860c56aec 100644
--- a/crates/radicle-remote-helper/src/main.rs
+++ b/crates/radicle-remote-helper/src/main.rs
@@ -310,10 +310,18 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
)?);
}
["list"] => {
- list::for_fetch(&url, &profile, &stored)?;
+ let refs = list::for_fetch(&url, &profile, &stored)?;
+ for line in refs {
+ println!("{line}");
+ }
+ println!();
}
["list", "for-push"] => {
- list::for_push(&profile, &stored)?;
+ let refs = list::for_push(&profile, &stored)?;
+ for line in refs {
+ println!("{line}");
+ }
+ println!();
}
[] => {
return Ok(());
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 --all-features cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 12528e3c-db31-4ce4-9a19-905145e5a366 -v /opt/radcis/ci.rad.levitte.org/cci/state/12528e3c-db31-4ce4-9a19-905145e5a366/s:/12528e3c-db31-4ce4-9a19-905145e5a366/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/12528e3c-db31-4ce4-9a19-905145e5a366/w:/12528e3c-db31-4ce4-9a19-905145e5a366/w -w /12528e3c-db31-4ce4-9a19-905145e5a366/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /12528e3c-db31-4ce4-9a19-905145e5a366/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.90-x86_64-unknown-linux-gnu'
info: latest update on 2025-09-18, rust version 1.90.0 (1159e78c4 2025-09-14)
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.90.0 (840b83a10 2025-07-30)
+ rustc --version
rustc 1.90.0 (1159e78c4 2025-09-14)
+ cargo fmt --check
Diff in /12528e3c-db31-4ce4-9a19-905145e5a366/w/crates/radicle-remote-helper/src/push.rs:464:
// refs will be announced eventually, when the node restarts.
if node.is_running() {
// Nb. allow this to fail. The push to local storage was still successful.
- node.sync(
- stored,
- ok.into_values().flatten().collect(),
- opts,
- profile,
- )
- .ok();
+ node.sync(stored, ok.into_values().flatten().collect(), opts, profile)
+ .ok();
} else if hints {
hint("offline push, your node is not running");
hint("to sync with the network, run `rad node start`");
Diff in /12528e3c-db31-4ce4-9a19-905145e5a366/w/crates/radicle-remote-helper/src/service.rs:23:
) -> io::Result<process::Output>;
/// Run `git send-pack` (via `radicle::git::run`).
- fn send_pack(
- &self,
- working: Option<&Path>,
- args: &[String],
- ) -> io::Result<process::Output>;
+ fn send_pack(&self, working: Option<&Path>, args: &[String]) -> io::Result<process::Output>;
}
/// Production implementation using real Git subprocesses.
Diff in /12528e3c-db31-4ce4-9a19-905145e5a366/w/crates/radicle-remote-helper/src/service.rs:44:
git::process::fetch_pack(working, stored, oids, verbosity)
}
- fn send_pack(
- &self,
- working: Option<&Path>,
- args: &[String],
- ) -> io::Result<process::Output> {
+ fn send_pack(&self, working: Option<&Path>, args: &[String]) -> io::Result<process::Output> {
git::run(working, args)
}
}
Exit code: 1
{
"response": "finished",
"result": "failure"
}