diff --git a/README.md b/README.md
index c160bdd..205d6c8 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Check the documentation at [n34.dev]
## Features
- [X] Repository announcements
-- [ ] Repository state announcements
+- [X] Repository state announcements
- [X] Patches (Send, fetch and list)
- [X] Issues (Send, view and list)
- [X] Replies
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 78a8509..d5d757d 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -15,6 +15,7 @@
- [Manage Repositories](repo/README.md)
- [Broadcast and Update a Git Repository](repo/announce.md)
- [View Git Repository Details](repo/view.md)
+ - [Repository State Announcements](repo/state.md)
- [Issue Management](issue/README.md)
- [Create an Issue](issue/new.md)
- [View an Issue By ID](issue/view.md)
diff --git a/docs/index.html b/docs/index.html
index 52f726e..c3646b3 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -355,8 +355,8 @@
Repository announcements
-
- Repository state announcements
+
+ Repository state announcements
@@ -400,7 +400,7 @@
- Signing using NIP-07 proxy (nostr-browser-signer-proxy)
+ Signing using NIP-07 proxy (nostr-browser-signer-proxy)
diff --git a/docs/repo/state.md b/docs/repo/state.md
new file mode 100644
index 0000000..da8732f
--- /dev/null
+++ b/docs/repo/state.md
@@ -0,0 +1,29 @@
+# Repository State Announcements
+
+> `n34 repo state` command
+
+**Usage:**
+```
+Repository state announcements
+
+Usage: n34 repo state [OPTIONS]
+
+Arguments:
+ Name of the repository's primary branch, such as 'master' or 'main'
+
+Options:
+ --repo Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`
+ --tags Tags to announce a state for, in the format `=`. Separated by comma
+ --branches Branches to announce a state for, in the format `=`. Separated by comma
+```
+
+This command allows you to announce your repository state, which is useful for
+pushing to permissionless git repositories like the [GRASP] relay. The relay
+will verify your repository state and permit pushing commits only if they match
+the announced state.
+
+To get the commit ID that a branch or tag points to, use `git rev-parse
+`. You can automate this process by creating a script to
+generate the required input for this command.
+
+[GRASP]: https://ngit.dev/grasp
diff --git a/src/cli/commands/repo/mod.rs b/src/cli/commands/repo/mod.rs
index 7ac8d7c..7389a00 100644
--- a/src/cli/commands/repo/mod.rs
+++ b/src/cli/commands/repo/mod.rs
@@ -16,12 +16,15 @@
/// `repo announce` subcommand
mod announce;
+/// `repo state` subcommand
+mod state;
/// `repo view` subcommand
mod view;
use clap::Subcommand;
use self::announce::AnnounceArgs;
+use self::state::StateArgs;
use self::view::ViewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
@@ -32,10 +35,12 @@ pub enum RepoSubcommands {
View(ViewArgs),
/// Broadcast and update a git repository
Announce(AnnounceArgs),
+ /// Repository state announcements
+ State(StateArgs),
}
impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
- crate::run_command!(self, options, & View Announce)
+ crate::run_command!(self, options, & View Announce State)
}
}
diff --git a/src/cli/commands/repo/state.rs b/src/cli/commands/repo/state.rs
new file mode 100644
index 0000000..64d5922
--- /dev/null
+++ b/src/cli/commands/repo/state.rs
@@ -0,0 +1,149 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use std::borrow::Cow;
+
+use clap::Args;
+use nostr::event::{Kind, Tag, TagKind};
+use nostr::{event::EventBuilder, hashes::sha1::Hash as Sha1Hash};
+
+use crate::nostr_utils::traits::ReposUtils;
+use crate::{
+ cli::{
+ CliOptions,
+ CommandRunner,
+ parsers,
+ traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
+ types::NaddrOrSet,
+ },
+ error::N34Result,
+ nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
+};
+
+/// Prefix for branch references in Git.
+const HEADS_REFS: &str = "refs/heads/";
+
+/// Prefix for tag references in Git.
+const TAGS_REFS: &str = "refs/tags/";
+
+/// Repository state announcements kind
+const REPO_STATE_KIND: Kind = Kind::Custom(30618);
+
+/// `HEAD` tag kind
+const HEAD_TAG_KIND: TagKind = TagKind::Custom(Cow::Borrowed("HEAD"));
+
+/// Arguments for the `repo state` command
+#[derive(Args, Debug)]
+pub struct StateArgs {
+ /// Repository address in `naddr` format (`naddr1...`), NIP-05 format
+ /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
+ ///
+ /// If omitted, looks for a `nostr-address` file.
+ #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
+ naddrs: Option>,
+ /// Tags to announce a state for, in the format `=`.
+ /// Separated by comma.
+ ///
+ /// Example: `v0.4.0=9aa3b62de02a63aa6a0d49efa7c484aa550cef56`.
+ #[arg(long, value_delimiter = ',', value_parser = parsers::name_and_sha1)]
+ tags: Vec<(String, Sha1Hash)>,
+ /// Branches to announce a state for, in the format
+ /// `=`. Separated by comma.
+ ///
+ /// Example: `master=9aa3b62de02a63aa6a0d49efa7c484aa550cef56`.
+ #[arg(long, value_delimiter = ',', value_parser = parsers::name_and_sha1)]
+ branches: Vec<(String, Sha1Hash)>,
+ /// Name of the repository's primary branch, such as 'master' or 'main'.
+ head: String,
+}
+
+impl CommandRunner for StateArgs {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
+ self.naddrs.flat_naddrs(&options.config.sets)?,
+ &utils::nostr_address_path()?,
+ )?)?;
+ let relays = options.relays.clone().flat_relays(&options.config.sets)?;
+ let client = NostrClient::init(&options, &relays).await;
+ let user_pubk = client.pubkey().await?;
+ client.add_relays(&naddrs.extract_relays()).await;
+
+ let repos = client
+ .fetch_repos(&naddrs.clone().into_coordinates())
+ .await?;
+ let repos_id = repos
+ .first()
+ .cloned()
+ .expect("It's not empty, checked above")
+ .id;
+
+ let mut event_builder = EventBuilder::new(REPO_STATE_KIND, "")
+ .dedup_tags()
+ .pow(options.pow.unwrap_or_default())
+ .tag(Tag::identifier(&repos_id))
+ .tag(Tag::custom(
+ HEAD_TAG_KIND,
+ &[format!("ref: {HEADS_REFS}{}", self.head)],
+ ));
+
+ if !self.branches.is_empty() {
+ event_builder = event_builder.tags(refs_tags::(self.branches));
+ }
+
+ if !self.tags.is_empty() {
+ event_builder = event_builder.tags(refs_tags::(self.tags));
+ }
+
+ let event = event_builder.build(user_pubk);
+ let event_id = event.id.expect("There is an id");
+ let user_relays_list = client.user_relays_list(user_pubk).await?;
+ let write_relays = [
+ relays,
+ naddrs.extract_relays(),
+ repos.extract_relays(),
+ utils::add_write_relays(user_relays_list.as_ref()),
+ // Include read relays for each maintainer (if found)
+ client
+ .read_relays_from_users(&repos.extract_maintainers())
+ .await,
+ ]
+ .concat();
+
+ tracing::trace!(relays = ?write_relays, "Write relays list");
+ let success = client
+ .send_event_to(event, user_relays_list.as_ref(), &write_relays)
+ .await?;
+
+ let nevent = utils::new_nevent(event_id, &success)?;
+ let naddr = utils::repo_naddr(repos_id, user_pubk, &success)?;
+ println!("Event created: {nevent}");
+ println!("State address: {naddr}");
+
+ Ok(())
+ }
+}
+
+/// Build the refs tags
+#[inline]
+fn refs_tags(refs: Vec<(String, Sha1Hash)>) -> impl IntoIterator- {
+ refs.into_iter().map(|(tag, commit)| {
+ Tag::parse(&[
+ format!("{}{tag}", if IS_HEADS { HEADS_REFS } else { TAGS_REFS }),
+ commit.to_string(),
+ ])
+ .expect("Not an empty tag")
+ })
+}
diff --git a/src/cli/parsers.rs b/src/cli/parsers.rs
index aba0950..02743ee 100644
--- a/src/cli/parsers.rs
+++ b/src/cli/parsers.rs
@@ -22,6 +22,7 @@ use std::{
use nostr::{
Kind,
+ hashes::sha1::Hash as Sha1Hash,
nips::{
nip19::{FromBech32, Nip19Coordinate, ToBech32},
nip46::NostrConnectURI,
@@ -84,6 +85,25 @@ pub fn parse_bunker_url(bunker_url: &str) -> N34Result {
}
}
+/// Parses a string in the format `string=sha1` into a name and SHA1 hash.
+/// The input must contain exactly one `=` character. If the format is invalid
+/// or the SHA1 hash is incorrect, an error is returned.
+pub fn name_and_sha1(value: &str) -> Result<(String, Sha1Hash), String> {
+ if value.chars().filter(|c| *c == '=').count() != 1 {
+ return Err("the syntax is `string=SHA1-commit-id`".to_owned());
+ }
+
+ let (name, sha1_hash) = value.split_once('=').expect("There is one `=`");
+
+ Ok((
+ name.trim_matches(['"', '\'']).to_owned(),
+ sha1_hash
+ .trim_matches(['"', '\''])
+ .parse()
+ .map_err(|_| "Invalid SHA1 commit id".to_owned())?,
+ ))
+}
+
/// Serializes a set of NIP-19 coordinates as a list of bech32 strings.
pub fn ser_naddrs
(naddr: &HashSet, serializer: S) -> Result
where