Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 13 additions & 31 deletions crates/buzz-cli/src/commands/repos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,14 @@ async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Resul
Ok(())
}

/// Build the kind:30617 announcement for `repos create`, including the
/// `buzz-channel` binding when requested.
/// Build the kind:30617 announcement for `repos create` with a required
/// `buzz-channel` binding.
///
/// Pure (no I/O) so the emitted tags are unit-testable. Exactly one
/// validated `buzz-channel` tag is appended — the tag is the git ACL
/// (issue #3527: without it the relay 404s every clone/fetch/push), so the
/// UUID is shape-validated here and its existence/membership is the relay's
/// authority at git-access time, same posture as `repos bind`.
/// (issue #3539 / #3527: without it the relay 404s every clone/fetch/push),
/// so the UUID is shape-validated here and its existence/membership is the
/// relay's authority at git-access time, same posture as `repos bind`.
#[allow(clippy::too_many_arguments)]
fn build_create_announcement(
repo_id: &str,
Expand All @@ -230,9 +230,10 @@ fn build_create_announcement(
clone_urls: &[String],
web_url: Option<&str>,
relays: &[String],
channel: Option<&str>,
channel: &str,
) -> Result<EventBuilder, CliError> {
validate_repo_id(repo_id)?;
crate::validate::validate_uuid(channel)?;

let clone_refs: Vec<&str> = clone_urls.iter().map(|s| s.as_str()).collect();
let relay_refs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect();
Expand All @@ -247,10 +248,7 @@ fn build_create_announcement(
)
.map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?;

if let Some(channel) = channel {
crate::validate::validate_uuid(channel)?;
builder = builder.tag(Tag::parse(["buzz-channel", channel]).map_err(tag_error)?);
}
builder = builder.tag(Tag::parse(["buzz-channel", channel]).map_err(tag_error)?);
Ok(builder)
}

Expand All @@ -263,7 +261,7 @@ pub async fn cmd_create_repo(
clone_urls: &[String],
web_url: Option<&str>,
relays: &[String],
channel: Option<&str>,
channel: &str,
) -> Result<(), CliError> {
let builder = build_create_announcement(
repo_id,
Expand Down Expand Up @@ -437,7 +435,7 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C
&clone_urls,
web.as_deref(),
&relays,
channel.as_deref(),
&channel,
)
.await
}
Expand Down Expand Up @@ -770,7 +768,7 @@ mod tests {
assert!(matches!(error, crate::error::CliError::Usage(_)));
}

/// Issue #3527: `repos create --channel` must emit exactly one
/// Issue #3539 / #3527: `repos create --channel` must emit exactly one
/// `buzz-channel` tag so the primary create command stops producing
/// repos the relay 404s forever.
#[test]
Expand All @@ -783,7 +781,7 @@ mod tests {
&["https://relay.example/git/owner/demo".to_string()],
None,
&[],
Some(&channel),
&channel,
)
.expect("build create announcement")
.sign_with_keys(&Keys::generate())
Expand All @@ -805,25 +803,9 @@ mod tests {
.any(|tag| tag.as_slice() == ["name", "Demo"]));
}

#[test]
fn create_without_channel_emits_no_binding_tag() {
let event = build_create_announcement("demo", None, None, &[], None, &[], None)
.expect("build create announcement")
.sign_with_keys(&Keys::generate())
.expect("sign create announcement");

assert!(
!event
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel")),
"no --channel means no binding tag (vanilla NIP-34 stays possible)"
);
}

#[test]
fn create_rejects_malformed_channel_uuid() {
let error = build_create_announcement("demo", None, None, &[], None, &[], Some("nope"))
let error = build_create_announcement("demo", None, None, &[], None, &[], "nope")
.expect_err("malformed channel id must not build an announcement");
assert!(matches!(error, crate::error::CliError::Usage(_)));
}
Expand Down
13 changes: 9 additions & 4 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,11 @@ pub enum NotesCmd {
#[derive(Subcommand)]
pub enum ReposCmd {
/// Announce a git repository (NIP-34)
#[command(
after_help = "The relay git read gate requires a buzz-channel binding. Always pass --channel.\n\n\
Examples:\n \
buzz repos create --id demo --channel <UUID> --clone https://<relay>/git/<pubkey>/demo"
)]
Create {
/// Repository identifier: [a-zA-Z0-9._-]{1,64}
#[arg(long)]
Expand All @@ -1132,11 +1137,11 @@ pub enum ReposCmd {
/// Preferred Nostr relay(s) for repo discovery — can be specified multiple times
#[arg(long = "nostr-relay")]
relays: Vec<String>,
/// Channel UUID to bind the repo to. The `buzz-channel` tag is the
/// git ACL: without it the relay 404s every clone/fetch/push until
/// the author runs `buzz repos bind` (issue #3527).
/// Channel UUID that binds this repo for the relay git read gate
/// (`buzz-channel` tag). Required — without it every clone/fetch/push
/// returns an opaque 404 until `buzz repos bind` (issue #3539 / #3527).
#[arg(long)]
channel: Option<String>,
channel: String,
},
/// Get a repository announcement
Get {
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/nest_skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Run `buzz agents draft-update --help` for optional runtime, provider, model, ren

## Git Repositories

Buzz hosts real git repos, and **you can own one yourself** — no human key needed. `repos create` signs the announcement with *your* key, so the repo is owned by whoever runs it; the owner segment in the clone URL is your own pubkey (hex, not a username). Git auth is automatic: the harness configures the `git-credential-nostr` helper, so plain `git clone`/`push`/`pull` against `<relay>/git/<your-pubkey>/<repo-id>` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id <id> --clone <relay>/git/<your-pubkey>/<id>`, then `git remote add origin <that-url>` and `git push -u origin main` (the relay seeds an empty repo on announce, so it's immediately pushable). Requires git 2.46+ for the credential protocol.
Buzz hosts real git repos, and **you can own one yourself** — no human key needed. `repos create` signs the announcement with *your* key, so the repo is owned by whoever runs it; the owner segment in the clone URL is your own pubkey (hex, not a username). Git auth is automatic: the harness configures the `git-credential-nostr` helper, so plain `git clone`/`push`/`pull` against `<relay>/git/<your-pubkey>/<repo-id>` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id <id> --channel <UUID> --clone <relay>/git/<your-pubkey>/<id>`, then `git remote add origin <that-url>` and `git push -u origin main` (the relay seeds an empty repo on announce, so it's immediately pushable). Requires git 2.46+ for the credential protocol.

Manage your repository's enforced branch and tag rules with `repos protect list|set|remove`. Ref patterns must use full Git names such as `refs/heads/main` or `refs/tags/*`; supported rules are `--push owner|admin|member`, `--no-force-push`, `--no-delete`, and `--require-patch`. `protect set` replaces the complete rule for that exact pattern, so omitted constraints are removed. Protection updates preserve every unrelated metadata tag and return exit code 5 when a newer NIP-33 head wins a concurrent write.

Expand Down